diff --git a/README.md b/README.md index f681263..6fb3a33 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ and team collaboration features. | **Advanced error reporting** | Detailed test failure/skip descriptions | ✅ | ✅ | ✅ | | **TestId import** | Import test IDs from testomat.io into the codebase | ✅ | ✅ | ✅ | | **Parametrized tests support** | Enhanced support for parameterized testing | ✅ | ✅ | ✅ | -| **Test artifacts support** | Screenshots, logs, and file attachments | ⏳ | ⏳ | ⏳ | +| **Test artifacts support** | Screenshots, logs, and file attachments | ✅ | ✅ | ✅ | | **Step-by-step reporting** | Detailed test step execution tracking | ⏳ | ⏳ | ⏳ | | **Other frameworks support** | Karate, Gauge, etc. (Priority may change) | | | | @@ -100,7 +100,9 @@ Create the `cucumber.properties` file if you don't have one yet and add this lin ```properties cucumber.plugin=io.testomat.cucumber.listener.CucumberListener ``` + --- + ### 🔧 Advanced Custom Setup > **⚠️ Only use this if you need custom behavior** - like adding extra logic to test lifecycle events. @@ -279,7 +281,75 @@ Use these oneliners to **download jar and update** ids in one move --- -## 💡 Usage Examples +## 📎 Test Artifacts Support + +The Java Reporter supports attaching files (screenshots, logs, videos, etc.) to your test results and uploading them to +S3-compatible storage. +Artifacts handling is enabled by default, but it won't affect the run if there are no artifacts provided (see options below). + +### Configuration + +Artifacts are stored in external S3 buckets. S3 Access can be configured in **two different ways**: + +1. Make configurations on the [Testomat.io](https://app.testomat.io): + Choose your project -> click **Settings** button on the left panel -> click **Artifacts** -> Toggle "**Share + credentials**..." + artifact example + +2. Provide options as environment variables/jvm property/testomatio.properties file. + +> NOTE: Environment variables(env/jvm/testomatio.properties) take precedence over server-provided credentials. + +| Setting | Description | Default | +|-------------------------------|--------------------------------------------------|-------------| +| `testomatio.artifact.disable` | Completely disable artifact uploading | `false` | +| `testomatio.artifact.private` | Keep artifacts private (no public URLs) | `false` | +| `s3.force-path-style` | Use path-style URLs for S3-compatible storage | `false` | +| `s3.endpoint` | Custom endpoint ot be used with force-path-style | `false` | +| `s3.bucket` | Provides bucket name for configuration | | +| `s3.access-key-id` | Access key for the bucket | | +| `s3.region` | Bucket region | `us-west-1` | + +**Note**: S3 credentials can be configured either in properties file or provided automatically on Testomat.io UI. +Environment variables take precedence over server-provided credentials. + +### Usage + +Use the `Testomatio` facade to attach files to your tests: +Multiple files can be provided to the `Testomatio.artifact(String ...)` method. + +```java +import io.testomat.core.facade.Testomatio; + +public class MyTest { + + @Test + public void testWithScreenshot() { + // Your test logic + + // Attach artifacts (screenshots, logs, etc.) + Testomatio.artifact( + "/path/to/screenshot.png", + "/path/to/test.log" + ); + } +} +``` +Please, make sure you provide path to artifact file including its extension. + +### How It Works + +1. **S3 Upload**: Files are uploaded to your S3 bucket with organized folder structure +2. **Link Generation**: Public URLs are generated and attached to test results +3. Artifacts are visible at the test info on UI + + +As the result you will see something like this on UI after run completed: +artifact example + +--- + +## 💡 Library Usage Examples ### Basic Usage diff --git a/img/artifactExample.png b/img/artifactExample.png new file mode 100644 index 0000000..95e848c Binary files /dev/null and b/img/artifactExample.png differ diff --git a/img/artifactsOnServerTurnOn.png b/img/artifactsOnServerTurnOn.png new file mode 100644 index 0000000..5cb67f7 Binary files /dev/null and b/img/artifactsOnServerTurnOn.png differ diff --git a/java-reporter-core/pom.xml b/java-reporter-core/pom.xml index e349833..594f464 100644 --- a/java-reporter-core/pom.xml +++ b/java-reporter-core/pom.xml @@ -7,7 +7,7 @@ io.testomat java-reporter-core - 0.6.8 + 0.7.9 jar Testomat.io Reporter Core @@ -67,6 +67,16 @@ jackson-databind ${jackson.version} + + + + + + + software.amazon.awssdk + aws-sdk-java + 2.33.6 + diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactLinkData.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactLinkData.java new file mode 100644 index 0000000..4e4c5d4 --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactLinkData.java @@ -0,0 +1,75 @@ +package io.testomat.core.artifact; + +import java.util.List; + +/** + * Data class representing the relationship between test execution and its associated artifact links. + * Contains test metadata and S3 URLs for uploaded artifacts. + */ +public class ArtifactLinkData { + private String rid; + private final String testId; + private final String testName; + + private final List links; + + /** + * Creates artifact link data for a test execution. + * + * @param testName name of the test + * @param rid request identifier + * @param testId unique test identifier + * @param links list of S3 URLs for uploaded artifacts + */ + public ArtifactLinkData(String testName, String rid, String testId, List links) { + this.testName = testName; + this.rid = rid; + this.testId = testId; + this.links = links; + } + + /** + * Returns the list of artifact URLs. + * + * @return list of S3 URLs for artifacts + */ + public List getLinks() { + return links; + } + + /** + * Returns the unique test identifier. + * + * @return test ID + */ + public String getTestId() { + return testId; + } + + /** + * Returns the request identifier. + * + * @return request ID + */ + public String getRid() { + return rid; + } + + /** + * Sets the request identifier. + * + * @param rid request ID to set + */ + public void setRid(String rid) { + this.rid = rid; + } + + /** + * Returns the test name. + * + * @return name of the test + */ + public String getTestName() { + return testName; + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactLinkDataStorage.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactLinkDataStorage.java new file mode 100644 index 0000000..805bec7 --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/ArtifactLinkDataStorage.java @@ -0,0 +1,13 @@ +package io.testomat.core.artifact; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Thread-safe storage for artifact link data collected during test execution. + * Maintains a collection of artifact links that will be associated with test results. + */ +public class ArtifactLinkDataStorage { + public static final List ARTEFACT_LINK_DATA_STORAGE = + new CopyOnWriteArrayList<>(); +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java new file mode 100644 index 0000000..5c85e7e --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/LinkUploadBodyBuilder.java @@ -0,0 +1,45 @@ +package io.testomat.core.artifact; + +/** + * Builder for creating JSON request bodies containing artifact links for upload to the server. + * Handles serialization of artifact data and test run information. + */ + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class LinkUploadBodyBuilder { + private static final Logger log = LoggerFactory.getLogger(LinkUploadBodyBuilder.class); + + public String buildLinkUploadRequestBody(List storedLinkData, String apiKey) { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode rootNode = mapper.createObjectNode(); + ArrayNode testsArray = mapper.createArrayNode(); + + for (ArtifactLinkData data : storedLinkData) { + ObjectNode testNode = mapper.createObjectNode(); + testNode.put("rid", data.getRid()); + testNode.put("test_id", data.getTestId()); + testNode.put("title", data.getTestName()); + testNode.put("overwrite", "true"); + testNode.set("artifacts", mapper.valueToTree(data.getLinks())); + testsArray.add(testNode); + } + + rootNode.put("api_key", apiKey); + rootNode.set("tests", testsArray); + + String json = null; + try { + json = mapper.writeValueAsString(rootNode); + } catch (JsonProcessingException e) { + log.warn("Failed to convert convert link storage to json body"); + } + return json; + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/ReportedTestStorage.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/ReportedTestStorage.java new file mode 100644 index 0000000..2668ac7 --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/ReportedTestStorage.java @@ -0,0 +1,52 @@ +package io.testomat.core.artifact; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Thread-safe storage for reported test data with artifact linking capabilities. + * Maintains test execution results and allows linking artifacts to specific tests by RID. + */ +public class ReportedTestStorage { + private static final Logger log = LoggerFactory.getLogger(ReportedTestStorage.class); + private static final List> STORAGE = new CopyOnWriteArrayList<>(); + + /** + * Stores test execution data. + * + * @param body test data map containing test results and metadata + */ + public static void store(Map body) { + STORAGE.add(body); + log.debug("Stored body: {}", body); + } + + /** + * Returns all stored test data. + * + * @return list of test data maps + */ + public static List> getStorage() { + return STORAGE; + } + + /** + * Links artifacts to their corresponding tests using RID matching. + * + * @param artifactLinkData list of artifact link data to associate with tests + */ + public static void linkArtifactsToTests(List artifactLinkData) { + for (ArtifactLinkData data : artifactLinkData) { + STORAGE.stream() + .filter(body -> data.getRid().equals(body.get("rid"))) + .forEach(body -> body.put("artifacts", data.getLinks())); + } + for (ArtifactLinkData data : artifactLinkData) { + log.debug("Linked: testId - {}, testName - {}, rid - {}, links - {}", + data.getTestId(), data.getTestName(), data.getRid(), data.getLinks().get(0)); + } + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/TempArtifactDirectoriesStorage.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/TempArtifactDirectoriesStorage.java new file mode 100644 index 0000000..d2f24fe --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/TempArtifactDirectoriesStorage.java @@ -0,0 +1,16 @@ +package io.testomat.core.artifact; + +import java.util.ArrayList; +import java.util.List; + +/** + * Thread-local storage for temporarily holding artifact file paths during test execution. + * Ensures thread safety when multiple tests run concurrently. + */ +public class TempArtifactDirectoriesStorage { + public static final ThreadLocal> DIRECTORIES = ThreadLocal.withInitial(ArrayList::new); + + public static void store(String dir) { + DIRECTORIES.get().add(dir); + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java new file mode 100644 index 0000000..df4c0c5 --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsClient.java @@ -0,0 +1,39 @@ +package io.testomat.core.artifact.client; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.s3.S3Client; + +/** + * AWS S3 client wrapper with built-in configuration management. + * Provides singleton S3Client instances with custom endpoint and credential support. + */ +public class AwsClient { + private static final Logger log = LoggerFactory.getLogger(AwsClient.class); + private volatile S3Client s3Client; + private final S3ClientFactory clientFactory; + + public AwsClient() { + this.clientFactory = new S3ClientFactory(); + } + + /** + * Test Constructor + */ + public AwsClient(S3ClientFactory s3ClientFactory) { + this.clientFactory = s3ClientFactory; + } + + /** + * Returns a configured S3Client instance using lazy initialization. + * + * @return configured S3Client instance + */ + public S3Client getS3Client() { + if (s3Client == null) { + s3Client = clientFactory.createS3Client(); + return s3Client; + } + return s3Client; + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java new file mode 100644 index 0000000..7a7d0aa --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/AwsService.java @@ -0,0 +1,240 @@ +package io.testomat.core.artifact.client; + +import io.testomat.core.artifact.ArtifactLinkData; +import io.testomat.core.artifact.ArtifactLinkDataStorage; +import io.testomat.core.artifact.TempArtifactDirectoriesStorage; +import io.testomat.core.artifact.credential.CredentialsManager; +import io.testomat.core.artifact.credential.S3Credentials; +import io.testomat.core.artifact.util.ArtifactKeyGenerator; +import io.testomat.core.artifact.util.ArtifactUrlGenerator; +import io.testomat.core.exception.ArtifactManagementException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +/** + * Service for managing S3 artifact uploads with automatic ACL fallback support. + * Handles file uploads to S3 buckets with intelligent ACL detection and caching. + */ +public class AwsService { + private static final Logger log = LoggerFactory.getLogger(AwsService.class); + private static final Map bucketAclSupport = new ConcurrentHashMap<>(); + + private static final String ACL_PRIVATE = "private"; + private static final String ACL_PUBLIC_READ = "public-read"; + private static final String ERROR_CODE_ACL_NOT_SUPPORTED = "AccessControlListNotSupported"; + private static final String ERROR_CODE_BUCKET_LOCKED = "BucketMustHaveLockedConfiguration"; + private static final String ERROR_MESSAGE_NO_ACL = "does not allow ACLs"; + + private final ArtifactKeyGenerator keyGenerator; + private final ArtifactUrlGenerator urlGenerator; + private final AwsClient awsClient; + + public AwsService() { + this.keyGenerator = new ArtifactKeyGenerator(); + this.awsClient = new AwsClient(); + this.urlGenerator = new ArtifactUrlGenerator(); + log.debug("AWS Service initialized"); + } + + public AwsService(ArtifactKeyGenerator keyGenerator, AwsClient awsClient, + ArtifactUrlGenerator urlGenerator) { + this.keyGenerator = keyGenerator; + this.awsClient = awsClient; + this.urlGenerator = urlGenerator; + } + + /** + * Uploads all artifacts for a specific test to S3. + * + * @param testName the name of the test + * @param rid the request identifier + * @param testId the unique test identifier + * @throws IllegalArgumentException if any parameter is null + */ + public void uploadAllArtifactsForTest(String testName, String rid, String testId) { + validateParameters(testName, rid, testId); + + List artifactDirectories = TempArtifactDirectoriesStorage.DIRECTORIES.get(); + if (artifactDirectories.isEmpty()) { + log.debug("Artifact list is empty for test: {}", testName); + return; + } + + S3Credentials credentials = CredentialsManager.getCredentials(); + List uploadedArtifactsLinks = processArtifacts(artifactDirectories, testName, rid, credentials); + + storeArtifactLinkData(testName, rid, testId, uploadedArtifactsLinks); + } + + private void validateParameters(String testName, String rid, String testId) { + Objects.requireNonNull(testName, "Test name cannot be null"); + Objects.requireNonNull(rid, "Request ID cannot be null"); + Objects.requireNonNull(testId, "Test ID cannot be null"); + } + + private List processArtifacts(List artifactDirectories, String testName, String rid, S3Credentials credentials) { + List uploadedLinks = new ArrayList<>(); + + for (String dir : artifactDirectories) { + String key = keyGenerator.generateKey(dir, rid, testName); + uploadArtifact(dir, key, credentials); + uploadedLinks.add(urlGenerator.generateUrl(credentials.getBucket(), key)); + } + + return uploadedLinks; + } + + private void storeArtifactLinkData(String testName, String rid, String testId, List uploadedLinks) { + ArtifactLinkData linkData = new ArtifactLinkData(testName, rid, testId, uploadedLinks); + ArtifactLinkDataStorage.ARTEFACT_LINK_DATA_STORAGE.add(linkData); + } + + private void uploadArtifact(String dir, String key, S3Credentials credentials) { + Objects.requireNonNull(dir, "Directory path cannot be null"); + Objects.requireNonNull(key, "S3 key cannot be null"); + Objects.requireNonNull(credentials, "S3 credentials cannot be null"); + + Path path = Paths.get(dir); + byte[] content = readFileContent(path); + + log.debug("Uploading to S3: bucket={}, key={}, size={} bytes", + credentials.getBucket(), key, content.length); + + uploadWithAclStrategy(path, key, credentials, content); + } + + private byte[] readFileContent(Path path) { + try { + byte[] content = Files.readAllBytes(path); + log.debug("Successfully read {} bytes from file: {}", content.length, path); + return content; + } catch (IOException e) { + log.error("Failed to read bytes from path: {}", path, e); + throw new ArtifactManagementException("Failed to read bytes from path: " + path, e); + } + } + + private void uploadWithAclStrategy(Path path, String key, S3Credentials credentials, byte[] content) { + String bucketName = credentials.getBucket(); + Boolean supportsAcl = bucketAclSupport.get(bucketName); + + if (supportsAcl == null) { + detectAndUpload(path, key, credentials, content, bucketName); + } else if (supportsAcl) { + performUploadWithAcl(path, key, credentials, content); + } else { + performUploadWithoutAcl(path, key, credentials, content); + } + } + + private void detectAndUpload(Path path, String key, S3Credentials credentials, byte[] content, String bucketName) { + boolean uploadSuccessful = tryUploadWithAcl(path, key, credentials, content); + if (uploadSuccessful) { + bucketAclSupport.put(bucketName, true); + } else { + bucketAclSupport.put(bucketName, false); + performUploadWithoutAcl(path, key, credentials, content); + } + } + + private boolean tryUploadWithAcl(Path path, String key, S3Credentials credentials, byte[] content) { + try { + PutObjectRequest request = buildUploadRequestWithAcl(credentials, key); + performS3Upload(request, content); + log.debug("S3 upload completed successfully with ACL for file: {}", path); + return true; + } catch (S3Exception e) { + return handleS3Exception(e, path, credentials, key); + } catch (Exception e) { + handleGenericException(e, path, credentials, key); + return false; + } + } + + private void performUploadWithAcl(Path path, String key, S3Credentials credentials, byte[] content) { + try { + PutObjectRequest request = buildUploadRequestWithAcl(credentials, key); + performS3Upload(request, content); + log.debug("S3 upload completed successfully with ACL for file: {}", path); + } catch (Exception e) { + handleUploadException(e, path, credentials, key); + } + } + + private PutObjectRequest buildUploadRequestWithAcl(S3Credentials credentials, String key) { + String acl = credentials.isPresign() ? ACL_PRIVATE : ACL_PUBLIC_READ; + return PutObjectRequest.builder() + .bucket(credentials.getBucket()) + .key(key) + .acl(acl) + .build(); + } + + private void performUploadWithoutAcl(Path path, String key, S3Credentials credentials, byte[] content) { + try { + PutObjectRequest request = buildUploadRequestWithoutAcl(credentials, key); + performS3Upload(request, content); + log.info("S3 upload completed successfully for file: {}", path); + } catch (Exception e) { + handleUploadException(e, path, credentials, key); + } + } + + private PutObjectRequest buildUploadRequestWithoutAcl(S3Credentials credentials, String key) { + return PutObjectRequest.builder() + .bucket(credentials.getBucket()) + .key(key) + .build(); + } + + private void performS3Upload(PutObjectRequest request, byte[] content) { + awsClient.getS3Client().putObject(request, RequestBody.fromBytes(content)); + } + + private boolean handleS3Exception(S3Exception e, Path path, S3Credentials credentials, String key) { + if (isAclNotSupportedError(e)) { + log.info("Bucket '{}' does not support ACLs, will retry without ACL", credentials.getBucket()); + return false; + } else { + handleUploadException(e, path, credentials, key); + return false; + } + } + + private void handleGenericException(Exception e, Path path, S3Credentials credentials, String key) { + log.error("S3 upload failed for file: {} to bucket: {}, key: {}", path, credentials.getBucket(), key, e); + throw new ArtifactManagementException("S3 upload failed: " + e.getMessage(), e); + } + + private void handleUploadException(Exception e, Path path, S3Credentials credentials, String key) { + if (e instanceof S3Exception) { + S3Exception s3e = (S3Exception) e; + log.error("S3 upload failed for file: {} to bucket: {}, key: {} - {} (Status: {})", + path, credentials.getBucket(), key, s3e.awsErrorDetails().errorMessage(), s3e.statusCode()); + throw new ArtifactManagementException("S3 upload failed: " + s3e.awsErrorDetails().errorMessage(), e); + } else { + log.error("S3 upload failed for file: {} to bucket: {}, key: {}", path, credentials.getBucket(), key, e); + throw new ArtifactManagementException("S3 upload failed: " + e.getMessage(), e); + } + } + + private boolean isAclNotSupportedError(S3Exception e) { + return (e.statusCode() == 400 && + (ERROR_CODE_ACL_NOT_SUPPORTED.equals(e.awsErrorDetails().errorCode()) || + ERROR_CODE_BUCKET_LOCKED.equals(e.awsErrorDetails().errorCode()) || + (e.awsErrorDetails().errorMessage() != null && + e.awsErrorDetails().errorMessage().contains(ERROR_MESSAGE_NO_ACL)))); + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/client/S3ClientFactory.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/S3ClientFactory.java new file mode 100644 index 0000000..8f8a0db --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/client/S3ClientFactory.java @@ -0,0 +1,75 @@ +package io.testomat.core.artifact.client; + +import io.testomat.core.artifact.credential.CredentialsManager; +import io.testomat.core.artifact.credential.S3Credentials; +import java.net.URI; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3ClientBuilder; +import software.amazon.awssdk.services.s3.S3Configuration; + +/** + * Factory for creating configured S3Client instances with custom endpoint support. + * Handles AWS credentials, regions, and S3-compatible storage configurations. + */ +public class S3ClientFactory { + + /** + * Creates a configured S3Client based on current credentials and settings. + * + * @return configured S3Client instance + * @throws IllegalArgumentException if credentials are invalid or missing + */ + public S3Client createS3Client() { + S3Credentials s3Credentials = CredentialsManager.getCredentials(); + + S3ClientBuilder builder = S3Client.builder(); + + AwsCredentialsProvider credentialsProvider; + if (s3Credentials.getAccessKeyId() != null && s3Credentials.getSecretAccessKey() != null) { + if (s3Credentials.getAccessKeyId().trim().isEmpty() || s3Credentials.getSecretAccessKey().trim().isEmpty()) { + throw new IllegalArgumentException("Access key and secret access key cannot be empty"); + } + AwsBasicCredentials credentials = AwsBasicCredentials.create(s3Credentials.getAccessKeyId().trim(), s3Credentials.getSecretAccessKey().trim()); + credentialsProvider = StaticCredentialsProvider.create(credentials); + } else { + throw new IllegalArgumentException("S3 credentials (access key and secret access key) must be configured"); + } + builder.credentialsProvider(credentialsProvider); + + if (s3Credentials.getRegion() != null && !s3Credentials.getRegion().trim().isEmpty()) { + try { + builder.region(Region.of(s3Credentials.getRegion().trim())); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid region: " + s3Credentials.getRegion(), e); + } + } else { + builder.region(Region.US_EAST_1); + } + + if (s3Credentials.getCustomEndpoint() != null && !s3Credentials.getCustomEndpoint().trim().isEmpty()) { + try { + builder.endpointOverride(URI.create(s3Credentials.getCustomEndpoint().trim())); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid endpoint URL: " + s3Credentials.getCustomEndpoint(), e); + } + + S3Configuration s3Config = S3Configuration.builder() + .pathStyleAccessEnabled(s3Credentials.isForcePath()) + .build(); + builder.serviceConfiguration(s3Config); + } else { + if (s3Credentials.isForcePath()) { + S3Configuration s3Config = S3Configuration.builder() + .pathStyleAccessEnabled(true) + .build(); + builder.serviceConfiguration(s3Config); + } + } + + return builder.build(); + } +} \ No newline at end of file diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsManager.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsManager.java new file mode 100644 index 0000000..5176603 --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsManager.java @@ -0,0 +1,148 @@ +package io.testomat.core.artifact.credential; + +import static io.testomat.core.constants.ArtifactPropertyNames.ACCESS_KEY_PROPERTY_NAME; +import static io.testomat.core.constants.ArtifactPropertyNames.BUCKET_PROPERTY_NAME; +import static io.testomat.core.constants.ArtifactPropertyNames.ENDPOINT_PROPERTY_NAME; +import static io.testomat.core.constants.ArtifactPropertyNames.FORCE_PATH_PROPERTY_NAME; +import static io.testomat.core.constants.ArtifactPropertyNames.PRIVATE_ARTIFACTS_PROPERTY_NAME; +import static io.testomat.core.constants.ArtifactPropertyNames.REGION_PROPERTY_NAME; +import static io.testomat.core.constants.ArtifactPropertyNames.SECRET_ACCESS_KEY_PROPERTY_NAME; +import static io.testomat.core.constants.CredentialConstants.ACCESS_KEY_ID; +import static io.testomat.core.constants.CredentialConstants.BUCKET; +import static io.testomat.core.constants.CredentialConstants.ENDPOINT; +import static io.testomat.core.constants.CredentialConstants.FORCE_PATH; +import static io.testomat.core.constants.CredentialConstants.IAM; +import static io.testomat.core.constants.CredentialConstants.PRESIGN; +import static io.testomat.core.constants.CredentialConstants.REGION; +import static io.testomat.core.constants.CredentialConstants.SECRET_ACCESS_KEY; +import static io.testomat.core.constants.CredentialConstants.SHARED; + +import io.testomat.core.propertyconfig.impl.PropertyProviderFactoryImpl; +import io.testomat.core.propertyconfig.interf.PropertyProvider; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Manages S3 credentials by combining environment variables and server-provided configuration. + * Provides centralized access to S3 credentials with priority given to environment variables. + */ +public class CredentialsManager { + private static final Logger log = LoggerFactory.getLogger(CredentialsManager.class); + private static final S3Credentials credentials = new S3Credentials(); + + + private final PropertyProvider provider = + PropertyProviderFactoryImpl.getPropertyProviderFactory().getPropertyProvider(); + + /** + * Returns the singleton S3Credentials instance. + * + * @return current S3 credentials + */ + public static S3Credentials getCredentials() { + return credentials; + } + + /** + * Populates S3 credentials from server response, with environment variables taking precedence. + * + * @param credsFromServer credentials map received from server + */ + public void populateCredentials(Map credsFromServer) { + log.debug("Populating S3 credentials"); + + if (credsFromServer == null || credsFromServer.isEmpty()) { + log.warn("Received null or empty credentials map"); + return; + } + populateCredentialField(FORCE_PATH_PROPERTY_NAME, FORCE_PATH, credsFromServer, "ForcePath", + value -> credentials.setForcePath(getBooleanValue(value))); + + populateCredentialField(ENDPOINT_PROPERTY_NAME, ENDPOINT, credsFromServer, "Endpoint", + value -> credentials.setCustomEndpoint(getStringValue(value))); + + populateCredentialField(PRIVATE_ARTIFACTS_PROPERTY_NAME, PRESIGN, credsFromServer, "Presign", + value -> credentials.setPresign(getBooleanValue(value))); + + populateCredentialField(SECRET_ACCESS_KEY_PROPERTY_NAME, SECRET_ACCESS_KEY, credsFromServer, "SecretAccessKey", + value -> credentials.setSecretAccessKey(getStringValue(value))); + + populateCredentialField(ACCESS_KEY_PROPERTY_NAME, ACCESS_KEY_ID, credsFromServer, "AccessKey", + value -> credentials.setAccessKeyId(getStringValue(value))); + + populateCredentialField(BUCKET_PROPERTY_NAME, BUCKET, credsFromServer, "Bucket", + value -> credentials.setBucket(getStringValue(value))); + + populateCredentialField(REGION_PROPERTY_NAME, REGION, credsFromServer, "Region", + value -> credentials.setRegion(getStringValue(value))); + + credentials.setIam(getBooleanValue(credsFromServer.get(IAM))); + credentials.setShared(getBooleanValue(credsFromServer.get(SHARED))); + + logCredentialsInitializationResult(); + } + + private boolean areCredentialsAvailable() { + boolean accessKeyAvailable = credentials.getAccessKeyId() != null; + boolean secretKeyAvailable = credentials.getSecretAccessKey() != null; + boolean bucketAvailable = credentials.getBucket() != null; + boolean regionAvailable = credentials.getRegion() != null; + + boolean allAvailable = accessKeyAvailable + && secretKeyAvailable + && bucketAvailable + && regionAvailable; + + if (!allAvailable) { + log.warn("Missing S3 credentials - accessKey: {}, secretKey: {}, bucket: {}, region: {}", + accessKeyAvailable, secretKeyAvailable, bucketAvailable, regionAvailable); + } + + return allAvailable; + } + + private Object getPropertyFromEnv(String propertyName) { + try { + return provider.getProperty(propertyName); + } catch (Exception e) { + return null; + } + } + + private void populateCredentialField(String envPropertyName, String serverKey, + Map credsFromServer, String fieldDisplayName, + java.util.function.Consumer setter) { + Object envValue = getPropertyFromEnv(envPropertyName); + if (envValue != null) { + setter.accept(envValue); + log.debug("{} from env", fieldDisplayName); + } else { + Object serverValue = credsFromServer.get(serverKey); + if (serverValue != null) { + setter.accept(serverValue); + } + log.debug("{} from server", fieldDisplayName); + } + } + + private String getStringValue(Object value) { + return value != null ? value.toString() : null; + } + + private boolean getBooleanValue(Object value) { + return value != null && Boolean.parseBoolean(value.toString()); + } + + private void logCredentialsInitializationResult() { + log.info("S3 credentials populated: bucket={}, region={}, presign={}, shared={}, iam={}", + credentials.getBucket(), credentials.getRegion(), + credentials.isPresign(), credentials.isShared(), credentials.isIam()); + + if (!areCredentialsAvailable()) { + log.error("Credentials population completed but essential fields are missing"); + } else { + log.debug("All required S3 credentials are available"); + } + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsValidationService.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsValidationService.java new file mode 100644 index 0000000..ed93507 --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/CredentialsValidationService.java @@ -0,0 +1,70 @@ +package io.testomat.core.artifact.credential; + +import io.testomat.core.artifact.client.AwsClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.HeadBucketRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +/** + * Service for validating S3 credentials by performing actual S3 operations. + * Uses HeadBucket operation to verify bucket access and credential validity. + */ +public class CredentialsValidationService { + private static final Logger log = LoggerFactory.getLogger(CredentialsValidationService.class); + private final AwsClient awsClient; + + public CredentialsValidationService() { + this.awsClient = new AwsClient(); + } + + public CredentialsValidationService(AwsClient awsClient) { + this.awsClient = awsClient; + } + + /** + * Validates S3 credentials by attempting a HeadBucket operation. + * + * @param creds the S3 credentials to validate + * @return true if credentials are valid and bucket is accessible, false otherwise + */ + public boolean areCredentialsValid(S3Credentials creds) { + if (creds == null) { + log.error("Cannot validate null S3 credentials"); + return false; + } + + if (creds.getAccessKeyId() == null || creds.getSecretAccessKey() == null || + creds.getRegion() == null || creds.getBucket() == null) { + log.error("S3 credentials validation failed: missing required fields - " + + "accessKey: {}, secretKey: {}, region: {}, bucket: {}", + creds.getAccessKeyId() != null, creds.getSecretAccessKey() != null, + creds.getRegion() != null, creds.getBucket() != null); + return false; + } + + log.debug("Validating S3 credentials for bucket: {} in region: {}", + creds.getBucket(), creds.getRegion()); + + try { + S3Client client = awsClient.getS3Client(); + HeadBucketRequest headBucketRequest = HeadBucketRequest.builder() + .bucket(creds.getBucket()) + .build(); + + client.headBucket(headBucketRequest); + log.info("S3 credentials validation successful for bucket: {} in region: {}", + creds.getBucket(), creds.getRegion()); + return true; + } catch (S3Exception e) { + log.error("S3 credentials validation failed for bucket: {} - {} (Status: {})", + creds.getBucket(), e.awsErrorDetails().errorMessage(), e.statusCode()); + return false; + } catch (Exception e) { + log.error("S3 connection error during validation for bucket: {} - {}", + creds.getBucket(), e.getMessage(), e); + return false; + } + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/S3Credentials.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/S3Credentials.java new file mode 100644 index 0000000..6d7c7d0 --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/credential/S3Credentials.java @@ -0,0 +1,89 @@ +package io.testomat.core.artifact.credential; + +/** + * Data class holding S3 configuration including credentials, bucket, and connection settings. + * Supports both AWS S3 and S3-compatible storage solutions. + */ +public class S3Credentials { + private boolean presign; + private boolean shared; + private boolean iam; + private String secretAccessKey; + private String accessKeyId; + private String bucket; + private String region; + private String customEndpoint; + private boolean forcePath = false; + + public boolean isForcePath() { + return forcePath; + } + + public void setForcePath(boolean forcePath) { + this.forcePath = forcePath; + } + + public boolean isPresign() { + return presign; + } + + public void setPresign(boolean presign) { + this.presign = presign; + } + + public boolean isShared() { + return shared; + } + + public void setShared(boolean shared) { + this.shared = shared; + } + + public boolean isIam() { + return iam; + } + + public void setIam(boolean iam) { + this.iam = iam; + } + + public String getSecretAccessKey() { + return secretAccessKey; + } + + public void setSecretAccessKey(String secretAccessKey) { + this.secretAccessKey = secretAccessKey; + } + + public String getAccessKeyId() { + return accessKeyId; + } + + public void setAccessKeyId(String accessKeyId) { + this.accessKeyId = accessKeyId; + } + + public String getBucket() { + return bucket; + } + + public void setBucket(String bucket) { + this.bucket = bucket; + } + + public String getRegion() { + return region; + } + + public void setRegion(String region) { + this.region = region; + } + + public String getCustomEndpoint() { + return customEndpoint; + } + + public void setCustomEndpoint(String customEndpoint) { + this.customEndpoint = customEndpoint; + } +} \ No newline at end of file diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/manager/ArtifactManager.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/manager/ArtifactManager.java new file mode 100644 index 0000000..19929b1 --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/manager/ArtifactManager.java @@ -0,0 +1,44 @@ +package io.testomat.core.artifact.manager; + +/** + * Manager for handling test artifacts with path validation and storage. + * Provides secure file path validation and artifact registration for test runs. + */ + +import io.testomat.core.artifact.TempArtifactDirectoriesStorage; +import io.testomat.core.exception.ArtifactManagementException; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ArtifactManager { + private static final Logger log = LoggerFactory.getLogger(ArtifactManager.class); + + public void storeDirectories(String... directories) { + for (String dir : directories) { + if (isValidFilePath(dir)) { + TempArtifactDirectoriesStorage.store(dir); + } else { + log.info("Invalid artifact path provided: {}", dir); + } + } + } + + private boolean isValidFilePath(String filePath) { + if (filePath == null || filePath.trim().isEmpty()) { + return false; + } + try { + Path path = Paths.get(filePath); + return Files.exists(path) && Files.isRegularFile(path); + } catch (InvalidPathException e) { + log.warn("Provided filepath is not valid: {}", filePath); + return false; + } catch (Exception e) { + throw new ArtifactManagementException("Unknown exception while file path validation", e); + } + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactKeyGenerator.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactKeyGenerator.java new file mode 100644 index 0000000..a2b1158 --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactKeyGenerator.java @@ -0,0 +1,25 @@ +package io.testomat.core.artifact.util; + +import java.nio.file.Paths; + +/** + * Utility class for generating S3 object keys for artifact uploads. + * Creates hierarchical key structure based on run ID, test name, and file path. + */ +public class ArtifactKeyGenerator { + private static String runId; + private static final String SEPARATOR = "/"; + + public static void initializeRunId(Object runId) { + ArtifactKeyGenerator.runId = runId.toString(); + } + + public String generateKey(String dir, String rid, String testName) { + return runId + + SEPARATOR + +testName + "::" + + rid + + SEPARATOR + + Paths.get(dir).getFileName(); + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactUrlGenerator.java b/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactUrlGenerator.java new file mode 100644 index 0000000..5003d36 --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/artifact/util/ArtifactUrlGenerator.java @@ -0,0 +1,31 @@ +package io.testomat.core.artifact.util; + +import io.testomat.core.artifact.client.AwsClient; +import software.amazon.awssdk.services.s3.model.GetUrlRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +/** + * Utility class for generating public and presigned URLs for S3 artifacts. + * Supports both direct S3 URLs and time-limited presigned URLs. + */ +public class ArtifactUrlGenerator { + private final AwsClient awsClient; + + + public ArtifactUrlGenerator() { + this.awsClient = new AwsClient(); + } + + public ArtifactUrlGenerator(AwsClient awsClient, S3Presigner s3Presigner) { + this.awsClient = awsClient; + } + + public String generateUrl(String bucket, String key) { + + GetUrlRequest request = GetUrlRequest.builder() + .bucket(bucket) + .key(key) + .build(); + return awsClient.getS3Client().utilities().getUrl(request).toString(); + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/client/ApiInterface.java b/java-reporter-core/src/main/java/io/testomat/core/client/ApiInterface.java index a4b6acd..6da1bed 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/client/ApiInterface.java +++ b/java-reporter-core/src/main/java/io/testomat/core/client/ApiInterface.java @@ -39,6 +39,8 @@ public interface ApiInterface { */ void reportTests(String uid, List results) throws IOException; + void sendTestWithArtifacts(String uid); + /** * Finalizes the test run and marks it as completed. * @@ -47,4 +49,6 @@ public interface ApiInterface { * @throws IOException if network request fails or API returns error */ void finishTestRun(String uid, float duration) throws IOException; + + void uploadLinksToTestomatio(String uid); } diff --git a/java-reporter-core/src/main/java/io/testomat/core/client/TestomatClientFactory.java b/java-reporter-core/src/main/java/io/testomat/core/client/TestomatClientFactory.java index 208976c..52a7764 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/client/TestomatClientFactory.java +++ b/java-reporter-core/src/main/java/io/testomat/core/client/TestomatClientFactory.java @@ -45,7 +45,7 @@ public ApiInterface createClient() { throw new ApiKeyNotFoundException( "Api key should be set in properties file or in JVM params."); } - return new NativeApiClient(apiKey, + return new TestomatioClient(apiKey, new NativeHttpClient(), new NativeRequestBodyBuilder()); } diff --git a/java-reporter-core/src/main/java/io/testomat/core/client/NativeApiClient.java b/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java similarity index 61% rename from java-reporter-core/src/main/java/io/testomat/core/client/NativeApiClient.java rename to java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java index bf31c6f..e05b3bb 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/client/NativeApiClient.java +++ b/java-reporter-core/src/main/java/io/testomat/core/client/TestomatioClient.java @@ -1,10 +1,19 @@ package io.testomat.core.client; +import static io.testomat.core.constants.CommonConstants.REPORTER_VERSION; +import static io.testomat.core.constants.CommonConstants.RESPONSE_UID_KEY; + +import io.testomat.core.artifact.ArtifactLinkDataStorage; +import io.testomat.core.artifact.ReportedTestStorage; +import io.testomat.core.artifact.LinkUploadBodyBuilder; +import io.testomat.core.artifact.credential.CredentialsManager; +import io.testomat.core.artifact.credential.CredentialsValidationService; import io.testomat.core.client.http.CustomHttpClient; import io.testomat.core.client.request.NativeRequestBodyBuilder; import io.testomat.core.client.request.RequestBodyBuilder; import io.testomat.core.client.urlbuilder.NativeUrlBuilder; import io.testomat.core.client.urlbuilder.UrlBuilder; +import io.testomat.core.exception.ArtifactManagementException; import io.testomat.core.exception.FinishReportFailedException; import io.testomat.core.exception.ReportingFailedException; import io.testomat.core.exception.RunCreationFailedException; @@ -15,22 +24,22 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import static io.testomat.core.constants.CommonConstants.REPORTER_VERSION; -import static io.testomat.core.constants.CommonConstants.RESPONSE_UID_KEY; - /** * Default HTTP-based implementation of {@link ApiInterface}. * Handles test run lifecycle and result reporting with comprehensive error handling * and automatic URL/request body construction for Testomat.io API endpoints. */ -public class NativeApiClient implements ApiInterface { - private static final Logger log = LoggerFactory.getLogger(NativeApiClient.class); +public class TestomatioClient implements ApiInterface { + private static final Logger log = LoggerFactory.getLogger(TestomatioClient.class); private final UrlBuilder urlBuilder = new NativeUrlBuilder(); private final String apiKey; private final CustomHttpClient client; private final RequestBodyBuilder requestBodyBuilder; + private final CredentialsManager credentialsManager = new CredentialsManager(); + private final LinkUploadBodyBuilder linkUploadBodyBuilder = new LinkUploadBodyBuilder(); + private final CredentialsValidationService credentialsValidationService = new CredentialsValidationService(); /** * Creates API client with injectable dependencies. @@ -40,9 +49,9 @@ public class NativeApiClient implements ApiInterface { * @param client HTTP client implementation for network requests * @param requestBodyBuilder builder for creating JSON request payloads */ - public NativeApiClient(String apiKey, - CustomHttpClient client, - NativeRequestBodyBuilder requestBodyBuilder) { + public TestomatioClient(String apiKey, + CustomHttpClient client, + NativeRequestBodyBuilder requestBodyBuilder) { this.apiKey = apiKey; this.client = client; this.requestBodyBuilder = requestBodyBuilder; @@ -56,16 +65,21 @@ public String createRun(String title) throws IOException { log.debug("Creating run with request url: {}", url); String requestBody = requestBodyBuilder.buildCreateRunBody(title); - Map responseBody = client.post(url, requestBody, Map.class); + Map responseBody = client.post(url, requestBody, Map.class); log.debug(responseBody.toString()); if (responseBody == null || !responseBody.containsKey(RESPONSE_UID_KEY)) { throw new RunCreationFailedException( "Invalid response: missing UID in create test run response"); } + if (responseBody.containsKey("artifacts")) { + Map creds = (Map) responseBody.get("artifacts"); + credentialsManager.populateCredentials(creds); + credentialsValidationService.areCredentialsValid(CredentialsManager.getCredentials()); + } logAndPrintUrls(responseBody); log.debug("Created test run with UID: {}", responseBody.get(RESPONSE_UID_KEY)); - return responseBody.get(RESPONSE_UID_KEY); + return responseBody.get(RESPONSE_UID_KEY).toString(); } @Override @@ -95,6 +109,7 @@ public void reportTests(String uid, List results) { String url = urlBuilder.buildReportTestUrl(uid); String requestBody = requestBodyBuilder.buildBatchTestReportBody(results, apiKey); + log.debug("Batch request body: {}", requestBody); client.post(url, requestBody, null); } catch (Exception e) { @@ -103,6 +118,20 @@ public void reportTests(String uid, List results) { } } + @Override + public void sendTestWithArtifacts(String uid) { + log.debug("Preparing to secondary batch sending with artifacts"); + String url = urlBuilder.buildReportTestUrl(uid); + try { + String requestBody = requestBodyBuilder.buildBatchReportBodyWithArtifacts( + ReportedTestStorage.getStorage(), apiKey); + client.post(url, requestBody, null); + log.debug("Sent requestBody: {}", requestBody); + } catch (Exception e) { + log.error("Failed while secondary batch sending with artifacts"); + } + } + @Override public void finishTestRun(String uid, float duration) { try { @@ -118,13 +147,28 @@ public void finishTestRun(String uid, float duration) { } } - private void logAndPrintUrls(Map responseBody) { - String publicUrl = responseBody.get("public_url"); + public void uploadLinksToTestomatio(String uid) { + + String requestBody = linkUploadBodyBuilder.buildLinkUploadRequestBody( + ArtifactLinkDataStorage.ARTEFACT_LINK_DATA_STORAGE, apiKey); + + String url = urlBuilder.buildReportTestUrl(uid); + log.debug("-> REQUEST BODY: {}", requestBody); + + try { + client.post(url, requestBody, null); + } catch (IOException e) { + throw new ArtifactManagementException("Failed to upload artifact links to Testomatio", e); + } + } + + private void logAndPrintUrls(Map responseBody) { + Object publicUrlObject = responseBody.get("public_url"); log.info("[TESTOMATIO] Testomat.io java core reporter version: [{}]", REPORTER_VERSION); - if (publicUrl != null) { - log.info("[TESTOMATIO] Public url: {}", publicUrl); + if (publicUrlObject != null) { + log.info("[TESTOMATIO] Public url: {}", publicUrlObject.toString()); } log.info("[TESTOMATIO] See run aggregation at: {}", responseBody.get("url")); diff --git a/java-reporter-core/src/main/java/io/testomat/core/client/request/NativeRequestBodyBuilder.java b/java-reporter-core/src/main/java/io/testomat/core/client/request/NativeRequestBodyBuilder.java index 0931488..a8978d0 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/client/request/NativeRequestBodyBuilder.java +++ b/java-reporter-core/src/main/java/io/testomat/core/client/request/NativeRequestBodyBuilder.java @@ -11,6 +11,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import io.testomat.core.artifact.ReportedTestStorage; import io.testomat.core.constants.ApiRequestFields; import io.testomat.core.exception.FailedToCreateRunBodyException; import io.testomat.core.model.TestResult; @@ -69,6 +70,8 @@ public String buildCreateRunBody(String title) { body.put("access_event", "publish"); } + body.put("overwrite", "true"); + return objectMapper.writeValueAsString(body); } catch (JsonProcessingException e) { @@ -97,6 +100,16 @@ public String buildBatchTestReportBody(List results, String apiKey) return objectMapper.writeValueAsString(requestBody); } + @Override + public String buildBatchReportBodyWithArtifacts(List> testsWithArtifacts, + String apiKey) throws JsonProcessingException { + Map requestBody = new HashMap<>(); + requestBody.put(API_KEY_STRING, apiKey); + requestBody.put(TESTS_STRING, testsWithArtifacts); + + return objectMapper.writeValueAsString(requestBody); + } + @Override public String buildFinishRunBody(float duration) throws JsonProcessingException { Map body = Map.of( @@ -106,6 +119,11 @@ public String buildFinishRunBody(float duration) throws JsonProcessingException return objectMapper.writeValueAsString(body); } + @Override + public String buildUploadLinksBody(String jsonString) { + return ""; + } + /** * Converts test result to map structure for JSON serialization. * Includes all standard fields plus support for parameterized test data. @@ -142,6 +160,8 @@ private Map buildTestResultMap(TestResult result) { body.put("create", TRUE); } + body.put("overwrite", "true"); + ReportedTestStorage.store(body); return body; } @@ -163,8 +183,7 @@ private String getPropertySafely(String propertyName) { private boolean getCreateParam() { try { - String property = provider.getProperty(CREATE_TEST_PROPERTY_NAME); - return property != null && !property.equalsIgnoreCase("0"); + return provider.getProperty(CREATE_TEST_PROPERTY_NAME).equalsIgnoreCase(TRUE); } catch (Exception e) { return false; } diff --git a/java-reporter-core/src/main/java/io/testomat/core/client/request/RequestBodyBuilder.java b/java-reporter-core/src/main/java/io/testomat/core/client/request/RequestBodyBuilder.java index 6c0cfa6..63deab5 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/client/request/RequestBodyBuilder.java +++ b/java-reporter-core/src/main/java/io/testomat/core/client/request/RequestBodyBuilder.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import io.testomat.core.model.TestResult; import java.util.List; +import java.util.Map; /** * Strategy interface for building JSON request bodies for Testomat.io API operations. @@ -32,7 +33,7 @@ public interface RequestBodyBuilder { * Creates JSON request body for batch test result reporting. * * @param results collection of test execution results - * @param apiKey API key for authentication in batch requests + * @param apiKey API key for authentication in batch requests * @return JSON-formatted request body string * @throws JsonProcessingException if JSON serialization fails */ @@ -47,4 +48,9 @@ String buildBatchTestReportBody(List results, String apiKey) * @throws JsonProcessingException if JSON serialization fails */ String buildFinishRunBody(float duration) throws JsonProcessingException; + + String buildUploadLinksBody(String jsonString); + + String buildBatchReportBodyWithArtifacts(List> testsWithArtifacts, + String apiKey) throws JsonProcessingException; } diff --git a/java-reporter-core/src/main/java/io/testomat/core/constants/ArtifactPropertyNames.java b/java-reporter-core/src/main/java/io/testomat/core/constants/ArtifactPropertyNames.java new file mode 100644 index 0000000..7e4e48b --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/constants/ArtifactPropertyNames.java @@ -0,0 +1,15 @@ +package io.testomat.core.constants; + +public class ArtifactPropertyNames { + public static final String BUCKET_PROPERTY_NAME = "s3.bucket"; + public static final String ACCESS_KEY_PROPERTY_NAME = "s3.access-key-id"; + public static final String SECRET_ACCESS_KEY_PROPERTY_NAME = " s3.secret.access-key-id"; + public static final String REGION_PROPERTY_NAME = "s3.region"; + public static final String ENDPOINT_PROPERTY_NAME = "s3.endpoint"; + + public static final String FORCE_PATH_PROPERTY_NAME = "s3.force-path-style"; + + public static final String PRIVATE_ARTIFACTS_PROPERTY_NAME = "testomatio.artifact.private"; + public static final String ARTIFACT_DISABLE_PROPERTY_NAME = "testomatio.artifact.disable"; + public static final String MAX_SIZE_ARTIFACTS_PROPERTY_NAME = "testomatio.artifact.max-size"; +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/constants/CommonConstants.java b/java-reporter-core/src/main/java/io/testomat/core/constants/CommonConstants.java index 6d3d463..74e0080 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/constants/CommonConstants.java +++ b/java-reporter-core/src/main/java/io/testomat/core/constants/CommonConstants.java @@ -1,7 +1,7 @@ package io.testomat.core.constants; public class CommonConstants { - public static final String REPORTER_VERSION = "0.6.6"; + public static final String REPORTER_VERSION = "0.7.8"; public static final String TESTS_STRING = "tests"; public static final String API_KEY_STRING = "api_key"; diff --git a/java-reporter-core/src/main/java/io/testomat/core/constants/CredentialConstants.java b/java-reporter-core/src/main/java/io/testomat/core/constants/CredentialConstants.java new file mode 100644 index 0000000..e90dbca --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/constants/CredentialConstants.java @@ -0,0 +1,16 @@ +package io.testomat.core.constants; + +public class CredentialConstants { + public static final String PRESIGN = "presign"; + public static final String SHARED = "shared"; + public static final String IAM = "iam"; + public static final String SECRET_ACCESS_KEY = "SECRET_ACCESS_KEY"; + public static final String ACCESS_KEY_ID = "ACCESS_KEY_ID"; + public static final String BUCKET = "BUCKET"; + public static final String REGION = "REGION"; + public static final String ENDPOINT = "ENDPOINT"; + public static final String FORCE_PATH = "FORCE_PATH_STYLE"; + + private CredentialConstants() { + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/constants/PropertyNameConstants.java b/java-reporter-core/src/main/java/io/testomat/core/constants/PropertyNameConstants.java index 1698e12..3d662ca 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/constants/PropertyNameConstants.java +++ b/java-reporter-core/src/main/java/io/testomat/core/constants/PropertyNameConstants.java @@ -3,6 +3,8 @@ public class PropertyNameConstants { public static final String PROPERTIES_FILE_NAME = "testomatio.properties"; + public static final String DISABLE_REPORTING_PROPERTY_NAME = "testomatio.reporting.disable"; + public static final String API_KEY_PROPERTY_NAME = "testomatio"; public static final String CREATE_TEST_PROPERTY_NAME = "testomatio.create"; public static final String HOST_URL_PROPERTY_NAME = "testomatio.url"; diff --git a/java-reporter-core/src/main/java/io/testomat/core/constants/PropertyValuesConstants.java b/java-reporter-core/src/main/java/io/testomat/core/constants/PropertyValuesConstants.java index 5a00356..8f10fa8 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/constants/PropertyValuesConstants.java +++ b/java-reporter-core/src/main/java/io/testomat/core/constants/PropertyValuesConstants.java @@ -1,8 +1,8 @@ package io.testomat.core.constants; public class PropertyValuesConstants { - public static final int DEFAULT_BATCH_SIZE = 10; - public static final int DEFAULT_FLUSH_INTERVAL_SECONDS = 60000; + public static final int DEFAULT_BATCH_SIZE = 100000; + public static final int DEFAULT_FLUSH_INTERVAL_SECONDS = 10; public static final String DEFAULT_URL = "https://app.testomat.io/"; public static final String DEFAULT_RUN_TITLE = "Default Run Title"; } diff --git a/java-reporter-core/src/main/java/io/testomat/core/exception/ArtifactManagementException.java b/java-reporter-core/src/main/java/io/testomat/core/exception/ArtifactManagementException.java new file mode 100644 index 0000000..0781301 --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/exception/ArtifactManagementException.java @@ -0,0 +1,11 @@ +package io.testomat.core.exception; + +public class ArtifactManagementException extends RuntimeException { + public ArtifactManagementException(String message) { + super(message); + } + + public ArtifactManagementException(String message, Throwable throwable) { + super(message, throwable); + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/facade/ServiceRegistryUtil.java b/java-reporter-core/src/main/java/io/testomat/core/facade/ServiceRegistryUtil.java new file mode 100644 index 0000000..9a50675 --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/facade/ServiceRegistryUtil.java @@ -0,0 +1,31 @@ +package io.testomat.core.facade; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Service registry utility for managing singleton service instances. + * Provides thread-safe lazy initialization of services using reflection. + */ +public class ServiceRegistryUtil { + private static final Map, Object> services = new ConcurrentHashMap<>(); + + /** + * Gets or creates a singleton instance of the specified service class. + * + * @param serviceClass the class of the service to retrieve + * @param the type of the service + * @return singleton instance of the service + * @throws RuntimeException if service cannot be instantiated + */ + @SuppressWarnings("unchecked") + public static T getService(Class serviceClass) { + return (T) services.computeIfAbsent(serviceClass, clazz -> { + try { + return clazz.getDeclaredConstructor().newInstance(); + } catch (Exception e) { + throw new RuntimeException("Cannot create service: " + clazz, e); + } + }); + } +} \ No newline at end of file diff --git a/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java b/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java new file mode 100644 index 0000000..6f84a18 --- /dev/null +++ b/java-reporter-core/src/main/java/io/testomat/core/facade/Testomatio.java @@ -0,0 +1,19 @@ +package io.testomat.core.facade; + +import io.testomat.core.artifact.manager.ArtifactManager; + +/** + * Main public API facade for Testomat.io integration. + * Provides simple static methods for test artifact management and reporting. + */ +public class Testomatio { + + /** + * Registers artifact files or directories to be uploaded for the current test. + * + * @param directories paths to files or directories containing test artifacts + */ + public static void artifact(String... directories) { + ServiceRegistryUtil.getService(ArtifactManager.class).storeDirectories(directories); + } +} diff --git a/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java b/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java index b3dccab..ea9b82f 100644 --- a/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java +++ b/java-reporter-core/src/main/java/io/testomat/core/runmanager/GlobalRunManager.java @@ -1,8 +1,12 @@ package io.testomat.core.runmanager; import static io.testomat.core.constants.PropertyNameConstants.CUSTOM_RUN_UID_PROPERTY_NAME; +import static io.testomat.core.constants.PropertyNameConstants.DISABLE_REPORTING_PROPERTY_NAME; import static io.testomat.core.constants.PropertyNameConstants.RUN_TITLE_PROPERTY_NAME; +import io.testomat.core.artifact.ArtifactLinkDataStorage; +import io.testomat.core.artifact.ReportedTestStorage; +import io.testomat.core.artifact.util.ArtifactKeyGenerator; import io.testomat.core.batch.BatchResultManager; import io.testomat.core.client.ApiInterface; import io.testomat.core.client.ClientFactory; @@ -23,10 +27,11 @@ * Thread-safe implementation supporting concurrent test execution. */ public class GlobalRunManager { - private static final GlobalRunManager INSTANCE = new GlobalRunManager(); private static final Logger log = LoggerFactory.getLogger(GlobalRunManager.class); - private final PropertyProvider provider - = PropertyProviderFactoryImpl.getPropertyProviderFactory().getPropertyProvider(); + private static volatile GlobalRunManager INSTANCE; + + private final PropertyProvider provider; + private final ClientFactory clientFactory; private final AtomicInteger activeSuites = new AtomicInteger(0); private final AtomicReference runUid = new AtomicReference<>(); private final AtomicReference batchManager = new AtomicReference<>(); @@ -34,42 +39,103 @@ public class GlobalRunManager { private final AtomicBoolean shutdownHookRegistered = new AtomicBoolean(false); private volatile long startTime; + /** + * Default constructor that initializes all dependencies internally. + * Used for normal application runtime. + */ private GlobalRunManager() { + this.provider = PropertyProviderFactoryImpl.getPropertyProviderFactory().getPropertyProvider(); + this.clientFactory = TestomatClientFactory.getClientFactory(); + } + + /** + * Constructor for testing purposes that allows dependency injection. + * Package-private to allow testing while preventing external instantiation. + * + * @param provider the property provider to use + * @param clientFactory the client factory to use + */ + GlobalRunManager(PropertyProvider provider, ClientFactory clientFactory) { + this.provider = provider; + this.clientFactory = clientFactory; } /** * Returns the singleton instance of GlobalRunManager. + * Uses double-checked locking for thread-safe lazy initialization. * * @return the global run manager instance */ public static GlobalRunManager getInstance() { + if (INSTANCE == null) { + synchronized (GlobalRunManager.class) { + if (INSTANCE == null) { + INSTANCE = new GlobalRunManager(); + } + } + } return INSTANCE; } + /** + * Package-private method for testing to set a custom instance. + * Should only be used in test scenarios. + * + * @param instance the instance to set + */ + static void setInstance(GlobalRunManager instance) { + synchronized (GlobalRunManager.class) { + INSTANCE = instance; + } + } + + /** + * Package-private method for testing to reset the singleton instance. + * Should only be used in test scenarios. + */ + static void resetInstance() { + synchronized (GlobalRunManager.class) { + INSTANCE = null; + } + } + /** * Initializes test run if not already initialized. * Creates API client, test run UID, batch manager, and registers shutdown hook. * Thread-safe operation that ensures single initialization. */ public synchronized void initializeIfNeeded() { - if (runUid.get() != null) { + if (runUid.get() != null || isReportingDisabled()) { return; } try { - ClientFactory clientFactory = TestomatClientFactory.getClientFactory(); - ApiInterface client = clientFactory.createClient(); - String uid = getCustomRunUid(client); + log.debug("Client factory initialized successfully"); + + ApiInterface client = this.clientFactory.createClient(); + log.debug("Client created successfully"); + + String uid = defineRunId(client); + log.debug("Uid defined successfully: {}", uid); + + ArtifactKeyGenerator.initializeRunId(uid); + log.debug("ArtifactKeyGenerator received uid: {}", uid); apiClient.set(client); + log.debug("Api client is set"); + runUid.set(uid); batchManager.set(new BatchResultManager(client, uid)); + log.debug("Batch manager is set"); + startTime = System.currentTimeMillis(); + log.debug("Start time = {}", startTime); registerShutdownHook(); + log.debug("Shutdown hook registered"); - log.debug("Global test run initialized with UID: {}", uid); + log.debug("Global run manger initialized with UID: {}", uid); } catch (Exception e) { log.error("Failed to initialize test run: {}", String.valueOf(e)); } @@ -128,41 +194,98 @@ public boolean isActive() { return runUid.get() != null; } + /** + * Gets the current run UID if available. + * + * @return the current run UID or null if not initialized + */ + public String getRunUid() { + return runUid.get(); + } + + /** + * Gets the number of currently active test suites. + * + * @return number of active suites + */ + public int getActiveSuitesCount() { + return activeSuites.get(); + } + /** * Finalizes test run by shutting down batch manager and closing API connection. * Calculates run duration and sends completion notification to Testomat.io. */ private void finalizeRun() { + finalizeRun(false); + } + + /** + * Finalizes test run with option to force finalization. + * + * @param force if true, forces finalization even if there are active suites + */ + private synchronized void finalizeRun(boolean force) { + if (!force && activeSuites.get() > 0) { + log.debug("Skipping finalization - {} active suites remaining", activeSuites.get()); + return; + } + + String uid = runUid.getAndSet(null); + if (uid == null) { + log.debug("Test run already finalized or not initialized"); + return; + } + BatchResultManager manager = batchManager.getAndSet(null); if (manager != null) { - manager.shutdown(); + try { + manager.shutdown(); + log.debug("Batch manager shutdown completed"); + } catch (Exception e) { + log.error("Error shutting down batch manager: {}", e.getMessage()); + } } - String uid = runUid.getAndSet(null); ApiInterface client = apiClient.getAndSet(null); - - if (uid != null && client != null) { + if (client != null) { try { float duration = (System.currentTimeMillis() - startTime) / 1000.0f; client.finishTestRun(uid, duration); - log.debug("Test run finished: {}", uid); + log.debug("Test run finished: {} (duration: {}s)", uid, duration); + + ReportedTestStorage.linkArtifactsToTests(ArtifactLinkDataStorage.ARTEFACT_LINK_DATA_STORAGE); + log.info("Syncing artifacts with Testomat.io"); + Thread.sleep(15000); + client.sendTestWithArtifacts(uid); + log.debug("Artifacts sent successfully for run: {}", uid); } catch (IOException e) { - log.error("Failed to finish test run{}", String.valueOf(e.getCause())); + log.error("Failed to finish test run {}: {}", uid, e.getMessage()); + } catch (Exception e) { + log.error("Unexpected error during test run finalization {}: {}", uid, e.getMessage()); } } } + /** + * Forces immediate finalization of the test run. + * Should be used cautiously as it ignores active suites. + */ + public void forceFinalize() { + log.warn("Forcing test run finalization"); + finalizeRun(true); + } + /** * Retrieves test run title from properties. * * @return configured run title or null if not set */ private String getRunTitle() { - return PropertyProviderFactoryImpl.getPropertyProviderFactory() - .getPropertyProvider().getProperty(RUN_TITLE_PROPERTY_NAME); + return this.provider.getProperty(RUN_TITLE_PROPERTY_NAME); } - private String getCustomRunUid(ApiInterface client) throws IOException { + private String defineRunId(ApiInterface client) throws IOException { String customUid; try { customUid = provider.getProperty(CUSTOM_RUN_UID_PROPERTY_NAME); @@ -171,4 +294,15 @@ private String getCustomRunUid(ApiInterface client) throws IOException { } return customUid; } + + private boolean isReportingDisabled() { + try { + return provider.getProperty(DISABLE_REPORTING_PROPERTY_NAME) != null + && !provider.getProperty(DISABLE_REPORTING_PROPERTY_NAME).trim().isEmpty() + && !provider.getProperty(DISABLE_REPORTING_PROPERTY_NAME).equalsIgnoreCase("0"); + } catch (Exception e) { + log.info("Reporting is disabled with testomatio.reporting.disabled=1"); + return false; + } + } } diff --git a/java-reporter-core/src/test/java/io/testomat/core/client/NativeApiClientTest.java b/java-reporter-core/src/test/java/io/testomat/core/client/TestomatioClientTest.java similarity index 91% rename from java-reporter-core/src/test/java/io/testomat/core/client/NativeApiClientTest.java rename to java-reporter-core/src/test/java/io/testomat/core/client/TestomatioClientTest.java index 8344acd..7e8ec3f 100644 --- a/java-reporter-core/src/test/java/io/testomat/core/client/NativeApiClientTest.java +++ b/java-reporter-core/src/test/java/io/testomat/core/client/TestomatioClientTest.java @@ -1,6 +1,7 @@ package io.testomat.core.client; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; import io.testomat.core.client.http.CustomHttpClient; import io.testomat.core.client.request.NativeRequestBodyBuilder; @@ -16,27 +17,27 @@ @ExtendWith(MockitoExtension.class) @DisplayName("NativeApiClient Tests") -class NativeApiClientTest { +class TestomatioClientTest { @Mock private CustomHttpClient mockHttpClient; - + @Mock private NativeRequestBodyBuilder mockRequestBodyBuilder; - private NativeApiClient apiClient; + private TestomatioClient apiClient; private final String apiKey = "test-api-key"; @BeforeEach void setUp() { - apiClient = new NativeApiClient(apiKey, mockHttpClient, mockRequestBodyBuilder); + apiClient = new TestomatioClient(apiKey, mockHttpClient, mockRequestBodyBuilder); } @Test @DisplayName("Should handle createRun method call") void createRun_ValidTitle_ShouldHandleCall() { String title = "Test Run Title"; - + // Test will fail due to real UrlBuilder trying to load properties // Verifying the method handles calls gracefully assertThrows(Exception.class, () -> apiClient.createRun(title)); @@ -87,7 +88,7 @@ void reportTests_NullResults_ShouldSkipReporting() { void finishTestRun_ValidInput_ShouldHandleCall() { String uid = "test-run-123"; float duration = 45.5f; - + // Test will fail due to property loading, verifying graceful handling assertThrows(Exception.class, () -> apiClient.finishTestRun(uid, duration)); } diff --git a/java-reporter-core/src/test/java/io/testomat/core/runmanager/GlobalRunManagerTest.java b/java-reporter-core/src/test/java/io/testomat/core/runmanager/GlobalRunManagerTest.java new file mode 100644 index 0000000..60fafd0 --- /dev/null +++ b/java-reporter-core/src/test/java/io/testomat/core/runmanager/GlobalRunManagerTest.java @@ -0,0 +1,430 @@ +package io.testomat.core.runmanager; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.testomat.core.batch.BatchResultManager; +import io.testomat.core.client.ApiInterface; +import io.testomat.core.client.ClientFactory; +import io.testomat.core.model.TestResult; +import io.testomat.core.propertyconfig.interf.PropertyProvider; +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +@DisplayName("GlobalRunManager Tests") +class GlobalRunManagerTest { + + @Mock + private PropertyProvider mockPropertyProvider; + + @Mock + private ClientFactory mockClientFactory; + + @Mock + private ApiInterface mockApiClient; + + @Mock + private BatchResultManager mockBatchManager; + + private GlobalRunManager globalRunManager; + + @BeforeEach + void setUp() throws Exception { + // Reset singleton instance before each test + GlobalRunManager.resetInstance(); + + // Configure default mock behavior + lenient().when(mockClientFactory.createClient()).thenReturn(mockApiClient); + lenient().when(mockApiClient.createRun(any())).thenReturn("test-run-uid-123"); + lenient().when(mockPropertyProvider.getProperty("testomatio.reporting.disable")).thenReturn(null); + lenient().when(mockPropertyProvider.getProperty("testomatio.run.title")).thenReturn("Test Run"); + lenient().when(mockPropertyProvider.getProperty("testomatio.run.id")).thenThrow(new RuntimeException("Property not found")); + + // Create instance with mocked dependencies + globalRunManager = new GlobalRunManager(mockPropertyProvider, mockClientFactory); + GlobalRunManager.setInstance(globalRunManager); + } + + @AfterEach + void tearDown() { + GlobalRunManager.resetInstance(); + } + + @Nested + @DisplayName("Singleton Pattern Tests") + class SingletonPatternTests { + + @Test + @DisplayName("Should return same instance on multiple getInstance calls") + void shouldReturnSameInstance() { + // Reset to test lazy initialization + GlobalRunManager.resetInstance(); + + GlobalRunManager instance1 = GlobalRunManager.getInstance(); + GlobalRunManager instance2 = GlobalRunManager.getInstance(); + + assertNotNull(instance1); + assertNotNull(instance2); + assertSame(instance1, instance2); + } + + @Test + @DisplayName("Should be thread-safe during initialization") + @Timeout(5) + void shouldBeThreadSafeDuringInitialization() throws InterruptedException { + GlobalRunManager.resetInstance(); + + int threadCount = 10; + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch endLatch = new CountDownLatch(threadCount); + AtomicReference[] instances = new AtomicReference[threadCount]; + + for (int i = 0; i < threadCount; i++) { + instances[i] = new AtomicReference<>(); + final int index = i; + new Thread(() -> { + try { + startLatch.await(); + instances[index].set(GlobalRunManager.getInstance()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + endLatch.countDown(); + } + }).start(); + } + + startLatch.countDown(); + assertTrue(endLatch.await(3, TimeUnit.SECONDS)); + + GlobalRunManager firstInstance = instances[0].get(); + assertNotNull(firstInstance); + + for (int i = 1; i < threadCount; i++) { + assertSame(firstInstance, instances[i].get(), + "All instances should be the same object"); + } + } + } + + @Nested + @DisplayName("Initialization Tests") + class InitializationTests { + + @Test + @DisplayName("Should initialize successfully with valid configuration") + void shouldInitializeSuccessfully() throws IOException { + globalRunManager.initializeIfNeeded(); + + assertTrue(globalRunManager.isActive()); + assertEquals("test-run-uid-123", globalRunManager.getRunUid()); + + verify(mockClientFactory).createClient(); + verify(mockApiClient).createRun("Test Run"); + } + + @Test + @DisplayName("Should not initialize when reporting is disabled") + void shouldNotInitializeWhenReportingDisabled() { + when(mockPropertyProvider.getProperty("testomatio.reporting.disable")).thenReturn("1"); + + globalRunManager.initializeIfNeeded(); + + assertFalse(globalRunManager.isActive()); + assertNull(globalRunManager.getRunUid()); + + verify(mockClientFactory, never()).createClient(); + } + + @Test + @DisplayName("Should not initialize twice") + void shouldNotInitializeTwice() throws IOException { + globalRunManager.initializeIfNeeded(); + assertTrue(globalRunManager.isActive()); + + // Second initialization should be skipped + globalRunManager.initializeIfNeeded(); + + verify(mockClientFactory, times(1)).createClient(); + verify(mockApiClient, times(1)).createRun(any()); + } + + @Test + @DisplayName("Should use custom run ID when provided") + void shouldUseCustomRunId() throws IOException { + // Reset mocks to avoid stubbing conflicts + reset(mockPropertyProvider, mockClientFactory, mockApiClient); + + // Configure fresh stubbing for this test + lenient().when(mockPropertyProvider.getProperty("testomatio.reporting.disable")).thenReturn(null); + lenient().when(mockPropertyProvider.getProperty("testomatio.run.title")).thenReturn("Test Run"); + when(mockPropertyProvider.getProperty("testomatio.run.id")).thenReturn("custom-run-id"); + when(mockClientFactory.createClient()).thenReturn(mockApiClient); + + globalRunManager.initializeIfNeeded(); + + assertTrue(globalRunManager.isActive()); + assertEquals("custom-run-id", globalRunManager.getRunUid()); + + verify(mockApiClient, never()).createRun(any()); + } + + @Test + @DisplayName("Should handle initialization errors gracefully") + void shouldHandleInitializationErrors() throws IOException { + when(mockClientFactory.createClient()).thenThrow(new RuntimeException("Client creation failed")); + + globalRunManager.initializeIfNeeded(); + + assertFalse(globalRunManager.isActive()); + assertNull(globalRunManager.getRunUid()); + } + } + + @Nested + @DisplayName("Suite Counter Tests") + class SuiteCounterTests { + + @Test + @DisplayName("Should increment and decrement suite counter correctly") + void shouldManageSuiteCounterCorrectly() { + assertEquals(0, globalRunManager.getActiveSuitesCount()); + + globalRunManager.incrementSuiteCounter(); + assertEquals(1, globalRunManager.getActiveSuitesCount()); + + globalRunManager.incrementSuiteCounter(); + assertEquals(2, globalRunManager.getActiveSuitesCount()); + + globalRunManager.decrementSuiteCounter(); + assertEquals(1, globalRunManager.getActiveSuitesCount()); + + globalRunManager.decrementSuiteCounter(); + assertEquals(0, globalRunManager.getActiveSuitesCount()); + } + + @Test + @DisplayName("Should initialize when incrementing suite counter") + void shouldInitializeWhenIncrementingSuiteCounter() { + assertFalse(globalRunManager.isActive()); + + globalRunManager.incrementSuiteCounter(); + + assertTrue(globalRunManager.isActive()); + assertEquals(1, globalRunManager.getActiveSuitesCount()); + } + + @Test + @DisplayName("Should be thread-safe for concurrent suite operations") + @Timeout(5) + void shouldBeThreadSafeForConcurrentSuiteOperations() throws InterruptedException { + int threadCount = 10; + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch endLatch = new CountDownLatch(threadCount * 2); + + // Start threads that increment + for (int i = 0; i < threadCount; i++) { + new Thread(() -> { + try { + startLatch.await(); + globalRunManager.incrementSuiteCounter(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + endLatch.countDown(); + } + }).start(); + } + + // Start threads that decrement + for (int i = 0; i < threadCount; i++) { + new Thread(() -> { + try { + startLatch.await(); + Thread.sleep(100); // Wait a bit to ensure some increments happen first + globalRunManager.decrementSuiteCounter(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + endLatch.countDown(); + } + }).start(); + } + + startLatch.countDown(); + assertTrue(endLatch.await(3, TimeUnit.SECONDS)); + + // Final count should be 0 (same number of increments and decrements) + assertEquals(0, globalRunManager.getActiveSuitesCount()); + } + } + + @Nested + @DisplayName("Test Reporting Tests") + class TestReportingTests { + + @Test + @DisplayName("Should report test when batch manager is available") + void shouldReportTestWhenBatchManagerAvailable() throws Exception { + // Initialize to set up batch manager + globalRunManager.initializeIfNeeded(); + + // Use reflection to inject mock batch manager + setBatchManager(mockBatchManager); + + TestResult testResult = new TestResult(); + testResult.setTestId("test-123"); + + globalRunManager.reportTest(testResult); + + verify(mockBatchManager).addResult(testResult); + } + + @Test + @DisplayName("Should handle test reporting when batch manager is null") + void shouldHandleTestReportingWhenBatchManagerNull() { + TestResult testResult = new TestResult(); + testResult.setTestId("test-123"); + + // Should not throw exception even when not initialized + assertDoesNotThrow(() -> globalRunManager.reportTest(testResult)); + } + + private void setBatchManager(BatchResultManager batchManager) throws Exception { + Field batchManagerField = GlobalRunManager.class.getDeclaredField("batchManager"); + batchManagerField.setAccessible(true); + AtomicReference batchManagerRef = + (AtomicReference) batchManagerField.get(globalRunManager); + batchManagerRef.set(batchManager); + } + } + + @Nested + @DisplayName("State Management Tests") + class StateManagementTests { + + @Test + @DisplayName("Should return correct active state") + void shouldReturnCorrectActiveState() { + assertFalse(globalRunManager.isActive()); + assertNull(globalRunManager.getRunUid()); + + globalRunManager.initializeIfNeeded(); + + assertTrue(globalRunManager.isActive()); + assertNotNull(globalRunManager.getRunUid()); + } + + @Test + @DisplayName("Should handle force finalization") + void shouldHandleForceFinalization() throws Exception { + globalRunManager.initializeIfNeeded(); + assertTrue(globalRunManager.isActive()); + + // Add active suites + globalRunManager.incrementSuiteCounter(); + globalRunManager.incrementSuiteCounter(); + assertEquals(2, globalRunManager.getActiveSuitesCount()); + + // Force finalization should work even with active suites + globalRunManager.forceFinalize(); + + assertFalse(globalRunManager.isActive()); + assertNull(globalRunManager.getRunUid()); + } + } + + @Nested + @DisplayName("Property Provider Tests") + class PropertyProviderTests { + + @Test + @DisplayName("Should handle reporting disabled with various values") + void shouldHandleReportingDisabledWithVariousValues() { + // Test with "1" + when(mockPropertyProvider.getProperty("testomatio.reporting.disable")).thenReturn("1"); + globalRunManager.initializeIfNeeded(); + assertFalse(globalRunManager.isActive()); + + // Reset and test with "true" + GlobalRunManager.resetInstance(); + globalRunManager = new GlobalRunManager(mockPropertyProvider, mockClientFactory); + GlobalRunManager.setInstance(globalRunManager); + when(mockPropertyProvider.getProperty("testomatio.reporting.disable")).thenReturn("true"); + globalRunManager.initializeIfNeeded(); + assertFalse(globalRunManager.isActive()); + + // Reset and test with "0" (should enable) + GlobalRunManager.resetInstance(); + globalRunManager = new GlobalRunManager(mockPropertyProvider, mockClientFactory); + GlobalRunManager.setInstance(globalRunManager); + when(mockPropertyProvider.getProperty("testomatio.reporting.disable")).thenReturn("0"); + globalRunManager.initializeIfNeeded(); + assertTrue(globalRunManager.isActive()); + } + + @Test + @DisplayName("Should handle missing properties gracefully") + void shouldHandleMissingPropertiesGracefully() { + // This test already passes with default stubbing from setUp() + // The default stubbing simulates the scenario where custom run ID is not found + globalRunManager.initializeIfNeeded(); + + // Should successfully initialize with auto-generated run ID + assertTrue(globalRunManager.isActive()); + assertEquals("test-run-uid-123", globalRunManager.getRunUid()); + } + } + + @Nested + @DisplayName("Error Handling Tests") + class ErrorHandlingTests { + + @Test + @DisplayName("Should handle API client creation failure") + void shouldHandleApiClientCreationFailure() throws IOException { + when(mockClientFactory.createClient()).thenThrow(new RuntimeException("Network error")); + + globalRunManager.initializeIfNeeded(); + + assertFalse(globalRunManager.isActive()); + assertNull(globalRunManager.getRunUid()); + } + + @Test + @DisplayName("Should handle run creation failure") + void shouldHandleRunCreationFailure() throws IOException { + when(mockApiClient.createRun(any())).thenThrow(new IOException("Server error")); + + globalRunManager.initializeIfNeeded(); + + assertFalse(globalRunManager.isActive()); + assertNull(globalRunManager.getRunUid()); + } + } +} \ No newline at end of file diff --git a/java-reporter-cucumber/pom.xml b/java-reporter-cucumber/pom.xml index 44d768c..96668ed 100644 --- a/java-reporter-cucumber/pom.xml +++ b/java-reporter-cucumber/pom.xml @@ -6,7 +6,7 @@ io.testomat java-reporter-cucumber - 0.7.2 + 0.7.9 jar Testomat.io Java Reporter Cucumber @@ -50,7 +50,7 @@ io.testomat java-reporter-core - 0.7.2 + 0.7.9 io.cucumber diff --git a/java-reporter-cucumber/src/main/java/io/testomat/cucumber/constructor/CucumberTestResultConstructor.java b/java-reporter-cucumber/src/main/java/io/testomat/cucumber/constructor/CucumberTestResultConstructor.java index bc97814..8ab1ffb 100644 --- a/java-reporter-cucumber/src/main/java/io/testomat/cucumber/constructor/CucumberTestResultConstructor.java +++ b/java-reporter-cucumber/src/main/java/io/testomat/cucumber/constructor/CucumberTestResultConstructor.java @@ -40,12 +40,15 @@ public TestResult constructTestRunResult(TestCaseFinished event) { ExceptionDetails exceptionDetails = testDataExtractor.extractExceptionDetails(event); + String fileName = testDataExtractor.extractFileName(event); + System.out.println("CucumberTestResultConstructor: extractFileName returned: " + fileName); + return TestResult.builder() .withStatus(testDataExtractor.getNormalizedStatus(event)) .withSuiteTitle(event.getTestCase().getUri().toString()) .withExample(testDataExtractor.createExample(event)) .withTestId(testDataExtractor.extractTestId(event)) - .withFile(testDataExtractor.extractFileName(event)) + .withFile(fileName) .withTitle(testDataExtractor.extractTitle(event)) .withRid(event.getTestCase().getId().toString()) .withMessage(exceptionDetails.getMessage()) diff --git a/java-reporter-cucumber/src/main/java/io/testomat/cucumber/extractor/TestDataExtractor.java b/java-reporter-cucumber/src/main/java/io/testomat/cucumber/extractor/TestDataExtractor.java index e932b78..686be36 100644 --- a/java-reporter-cucumber/src/main/java/io/testomat/cucumber/extractor/TestDataExtractor.java +++ b/java-reporter-cucumber/src/main/java/io/testomat/cucumber/extractor/TestDataExtractor.java @@ -115,18 +115,7 @@ public String extractTitle(TestCaseFinished event) { */ public String extractFileName(TestCaseFinished event) { try { - String path = event.getTestCase().getUri().getPath(); - - if (path.contains(":") && !path.startsWith("/")) { - int colonIndex = path.indexOf(":"); - if (colonIndex < path.length() - 1) { - path = path.substring(colonIndex + 1); - } - } - - int lastSlash = path.lastIndexOf('/'); - return lastSlash != -1 ? path.substring(lastSlash + 1) : path; - + return event.getTestCase().getUri().toString(); } catch (Exception e) { return null; } diff --git a/java-reporter-cucumber/src/main/java/io/testomat/cucumber/listener/CucumberListener.java b/java-reporter-cucumber/src/main/java/io/testomat/cucumber/listener/CucumberListener.java index 5f812ef..d9e275d 100644 --- a/java-reporter-cucumber/src/main/java/io/testomat/cucumber/listener/CucumberListener.java +++ b/java-reporter-cucumber/src/main/java/io/testomat/cucumber/listener/CucumberListener.java @@ -6,11 +6,13 @@ import io.cucumber.plugin.event.TestCaseFinished; import io.cucumber.plugin.event.TestRunFinished; import io.cucumber.plugin.event.TestRunStarted; +import io.testomat.core.artifact.client.AwsService; import io.testomat.core.exception.ReportTestResultException; import io.testomat.core.model.TestResult; import io.testomat.core.runmanager.GlobalRunManager; import io.testomat.cucumber.constructor.CucumberTestResultConstructor; import io.testomat.cucumber.exception.CucumberListenerException; +import io.testomat.cucumber.extractor.TestDataExtractor; /** * Cucumber plugin for Testomat.io integration. @@ -19,13 +21,17 @@ public class CucumberListener implements Plugin, EventListener { private final GlobalRunManager runManager; private final CucumberTestResultConstructor resultConstructor; + private final AwsService awsService; + private final TestDataExtractor dataExtractor; /** * Creates a new listener with default dependencies. */ public CucumberListener() { + this.dataExtractor = new TestDataExtractor(); this.runManager = GlobalRunManager.getInstance(); this.resultConstructor = new CucumberTestResultConstructor(); + this.awsService = new AwsService(); } /** @@ -33,12 +39,15 @@ public CucumberListener() { * Used primarily for testing with mocked dependencies. * * @param resultConstructor the test result constructor - * @param runManager the global run manager + * @param runManager the global run manager */ public CucumberListener(CucumberTestResultConstructor resultConstructor, - GlobalRunManager runManager) { + GlobalRunManager runManager, + AwsService awsService, TestDataExtractor dataExtractor) { this.runManager = runManager; this.resultConstructor = resultConstructor; + this.awsService = awsService; + this.dataExtractor = dataExtractor; } /** @@ -72,6 +81,20 @@ void handleTestCaseFinished(TestCaseFinished event) { String testName = event.getTestCase() != null ? event.getTestCase().getName() : "Unknown Test"; throw new ReportTestResultException("Failed to report test result for: " + testName, e); + } finally { + afterEach(event); } } + + /** + * Called after each test case execution, similar to JUnit/TestNG afterEach. + * Override this method to add custom post-test logic. + * + * @param event the test case finished event + */ + protected void afterEach(TestCaseFinished event) { + awsService.uploadAllArtifactsForTest(dataExtractor.extractTitle(event), + event.getTestCase().getId().toString(), + dataExtractor.extractTestId(event)); + } } diff --git a/java-reporter-cucumber/src/test/java/io/testomat/cucumber/constructor/CucumberTestResultConstructorTest.java b/java-reporter-cucumber/src/test/java/io/testomat/cucumber/constructor/CucumberTestResultConstructorTest.java index 0841d7c..923028e 100644 --- a/java-reporter-cucumber/src/test/java/io/testomat/cucumber/constructor/CucumberTestResultConstructorTest.java +++ b/java-reporter-cucumber/src/test/java/io/testomat/cucumber/constructor/CucumberTestResultConstructorTest.java @@ -64,7 +64,7 @@ void shouldConstructTestRunResultWithAllFields() { when(testDataExtractor.getNormalizedStatus(testCaseFinished)).thenReturn("PASSED"); when(testDataExtractor.createExample(testCaseFinished)).thenReturn(example); when(testDataExtractor.extractTestId(testCaseFinished)).thenReturn("@T12345678"); - when(testDataExtractor.extractFileName(testCaseFinished)).thenReturn("TestFeature.feature"); + when(testDataExtractor.extractFileName(testCaseFinished)).thenReturn("file:///test/path/TestFeature.feature"); when(testDataExtractor.extractTitle(testCaseFinished)).thenReturn("Test Title"); // When @@ -76,7 +76,7 @@ void shouldConstructTestRunResultWithAllFields() { assertEquals("file:///test/path/TestFeature.feature", result.getSuiteTitle()); assertEquals(example, result.getExample()); assertEquals("@T12345678", result.getTestId()); - assertEquals("TestFeature.feature", result.getFile()); + assertEquals("file:///test/path/TestFeature.feature", result.getFile()); assertEquals("Test Title", result.getTitle()); assertEquals(testCaseId.toString(), result.getRid()); assertEquals("Test error", result.getMessage()); @@ -131,7 +131,7 @@ void shouldVerifyAllExtractorMethodsAreCalled() { when(testDataExtractor.getNormalizedStatus(any())).thenReturn("PASSED"); when(testDataExtractor.createExample(any())).thenReturn(new HashMap<>()); when(testDataExtractor.extractTestId(any())).thenReturn(null); - when(testDataExtractor.extractFileName(any())).thenReturn("test.feature"); + when(testDataExtractor.extractFileName(any())).thenReturn("file:///test.feature"); when(testDataExtractor.extractTitle(any())).thenReturn("Test"); // When diff --git a/java-reporter-cucumber/src/test/java/io/testomat/cucumber/extractor/TestDataExtractorTest.java b/java-reporter-cucumber/src/test/java/io/testomat/cucumber/extractor/TestDataExtractorTest.java index 2046f86..a08937f 100644 --- a/java-reporter-cucumber/src/test/java/io/testomat/cucumber/extractor/TestDataExtractorTest.java +++ b/java-reporter-cucumber/src/test/java/io/testomat/cucumber/extractor/TestDataExtractorTest.java @@ -244,7 +244,7 @@ void shouldExtractFileNameFromUri() { String fileName = extractor.extractFileName(testCaseFinished); // Then - assertEquals("TestFeature.feature", fileName); + assertEquals("file:///path/to/test/TestFeature.feature", fileName); } @Test @@ -258,7 +258,7 @@ void shouldExtractFileNameFromWindowsPath() { String fileName = extractor.extractFileName(testCaseFinished); // Then - assertEquals("TestFeature.feature", fileName); + assertEquals("file:///C:/path/to/test/TestFeature.feature", fileName); } @Test @@ -272,7 +272,7 @@ void shouldHandleColonInPath() { String fileName = extractor.extractFileName(testCaseFinished); // Then - assertEquals("TestFeature.feature", fileName); + assertEquals("file://C:/Users/test/features/TestFeature.feature", fileName); } @Test @@ -432,4 +432,76 @@ void shouldReturnUnknownTestWhenTestCaseThrowsException() { // Then assertEquals("Unknown test", title); } + + @Test + void shouldExtractFileNameReturnsUriToString() { + // Given + URI testUri = URI.create("classpath:features/TestFeature.feature"); + when(testCaseFinished.getTestCase()).thenReturn(testCase); + when(testCase.getUri()).thenReturn(testUri); + + // When + String fileName = extractor.extractFileName(testCaseFinished); + + // Then + assertEquals("classpath:features/TestFeature.feature", fileName); + } + + @Test + void shouldHandleHttpUriInExtractFileName() { + // Given + URI testUri = URI.create("http://example.com/test.feature"); + when(testCaseFinished.getTestCase()).thenReturn(testCase); + when(testCase.getUri()).thenReturn(testUri); + + // When + String fileName = extractor.extractFileName(testCaseFinished); + + // Then + assertEquals("http://example.com/test.feature", fileName); + } + + @Test + void shouldHandleNullTestCaseInExtractFileName() { + // Given + when(testCaseFinished.getTestCase()).thenReturn(null); + + // When + String fileName = extractor.extractFileName(testCaseFinished); + + // Then + assertNull(fileName); + } + + @Test + void shouldParseMultipleTypesFromSingleStep() { + // Given + when(testCaseFinished.getTestCase()).thenReturn(testCase); + when(testCase.getTestSteps()).thenReturn(Collections.singletonList(pickleStepTestStep)); + when(pickleStepTestStep.getStepText()).thenReturn("User enters \"test@example.com\" and password \"secret123\" and age 25 and amount 99.99"); + + // When + Map example = extractor.createExample(testCaseFinished); + + // Then + assertEquals("test@example.com", example.get("step_value_0")); + assertEquals("secret123", example.get("step_value_1")); + assertEquals(25L, example.get("step_value_2")); + assertEquals(99.99, example.get("step_value_3")); + } + + @Test + void shouldCreateExampleWithBooleanValues() { + // Given + when(testCaseFinished.getTestCase()).thenReturn(testCase); + when(testCase.getTestSteps()).thenReturn(Collections.singletonList(pickleStepTestStep)); + when(pickleStepTestStep.getStepText()).thenReturn("Set active to 'true' and visible to 'false'"); + + // When + Map example = extractor.createExample(testCaseFinished); + + // Then + assertEquals("true", example.get("step_value_0")); + assertEquals("false", example.get("step_value_1")); + } } \ No newline at end of file diff --git a/java-reporter-cucumber/src/test/java/io/testomat/cucumber/listener/CucumberListenerTest.java b/java-reporter-cucumber/src/test/java/io/testomat/cucumber/listener/CucumberListenerTest.java index 5be3777..a0e11b7 100644 --- a/java-reporter-cucumber/src/test/java/io/testomat/cucumber/listener/CucumberListenerTest.java +++ b/java-reporter-cucumber/src/test/java/io/testomat/cucumber/listener/CucumberListenerTest.java @@ -5,29 +5,38 @@ import io.cucumber.plugin.event.TestCaseFinished; import io.cucumber.plugin.event.TestRunFinished; import io.cucumber.plugin.event.TestRunStarted; +import io.testomat.core.artifact.client.AwsService; import io.testomat.core.exception.ReportTestResultException; import io.testomat.core.model.TestResult; import io.testomat.core.runmanager.GlobalRunManager; import io.testomat.cucumber.constructor.CucumberTestResultConstructor; import io.testomat.cucumber.exception.CucumberListenerException; +import io.testomat.cucumber.extractor.TestDataExtractor; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import java.util.UUID; + import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; class CucumberListenerTest { - @Mock private CucumberTestResultConstructor resultConstructor; @Mock private GlobalRunManager runManager; + @Mock + private AwsService awsService; + + @Mock + private TestDataExtractor dataExtractor; + @Mock private EventPublisher eventPublisher; @@ -51,9 +60,8 @@ class CucumberListenerTest { @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); - listener = new CucumberListener(resultConstructor, runManager); + listener = new CucumberListener(resultConstructor, runManager, awsService, dataExtractor); } - @Test void shouldCreateDefaultConstructor() { CucumberListener defaultListener = new CucumberListener(); @@ -70,40 +78,43 @@ void shouldRegisterEventHandlers() { verify(eventPublisher).registerHandlerFor(eq(TestRunFinished.class), any()); verify(eventPublisher).registerHandlerFor(eq(TestCaseFinished.class), any()); } - - @Test - void shouldIncrementSuiteCounterOnTestRunStarted() { - // Given - when(runManager.isActive()).thenReturn(true); - listener.setEventPublisher(eventPublisher); - - // Verify that the handler was registered - verify(eventPublisher).registerHandlerFor(eq(TestRunStarted.class), any()); - - // We can't easily test the lambda directly, but we can verify the registration - verify(runManager, never()).incrementSuiteCounter(); - } - - @Test - void shouldDecrementSuiteCounterOnTestRunFinished() { - // Given - when(runManager.isActive()).thenReturn(true); - listener.setEventPublisher(eventPublisher); - - // Verify that the handler was registered - verify(eventPublisher).registerHandlerFor(eq(TestRunFinished.class), any()); - - // We can't easily test the lambda directly, but we can verify the registration - verify(runManager, never()).decrementSuiteCounter(); - } - +// +// @Test +// void shouldIncrementSuiteCounterOnTestRunStarted() { +// // Given +// when(runManager.isActive()).thenReturn(true); +// listener.setEventPublisher(eventPublisher); +// +// // Verify that the handler was registered +// verify(eventPublisher).registerHandlerFor(eq(TestRunStarted.class), any()); +// +// // We can't easily test the lambda directly, but we can verify the registration +// verify(runManager, never()).incrementSuiteCounter(); +// } +// +// @Test +// void shouldDecrementSuiteCounterOnTestRunFinished() { +// // Given +// when(runManager.isActive()).thenReturn(true); +// listener.setEventPublisher(eventPublisher); +// +// // Verify that the handler was registered +// verify(eventPublisher).registerHandlerFor(eq(TestRunFinished.class), any()); +// +// // We can't easily test the lambda directly, but we can verify the registration +// verify(runManager, never()).decrementSuiteCounter(); +// } @Test void shouldHandleTestCaseFinishedWhenRunManagerIsActive() { // Given when(runManager.isActive()).thenReturn(true); + UUID testCaseId = UUID.randomUUID(); when(testCaseFinished.getTestCase()).thenReturn(testCase); when(testCase.getName()).thenReturn("Test Scenario"); + when(testCase.getId()).thenReturn(testCaseId); when(resultConstructor.constructTestRunResult(testCaseFinished)).thenReturn(testResult); + when(dataExtractor.extractTitle(testCaseFinished)).thenReturn("Test Title"); + when(dataExtractor.extractTestId(testCaseFinished)).thenReturn("@T12345"); // When listener.handleTestCaseFinished(testCaseFinished); @@ -111,100 +122,120 @@ void shouldHandleTestCaseFinishedWhenRunManagerIsActive() { // Then verify(resultConstructor).constructTestRunResult(testCaseFinished); verify(runManager).reportTest(testResult); + verify(awsService).uploadAllArtifactsForTest("Test Title", testCaseId.toString(), "@T12345"); } - @Test - void shouldNotHandleTestCaseFinishedWhenRunManagerIsInactive() { + void shouldWrapExceptionWhenConstructorFails() { // Given - when(runManager.isActive()).thenReturn(false); + when(runManager.isActive()).thenReturn(true); + UUID testCaseId = UUID.randomUUID(); + RuntimeException originalException = new RuntimeException("Constructor error"); + when(testCaseFinished.getTestCase()).thenReturn(testCase); + when(testCase.getName()).thenReturn("Test Scenario"); + when(testCase.getId()).thenReturn(testCaseId); + when(dataExtractor.extractTitle(testCaseFinished)).thenReturn("Test Title"); + when(dataExtractor.extractTestId(testCaseFinished)).thenReturn("@T12345"); + when(resultConstructor.constructTestRunResult(testCaseFinished)) + .thenThrow(originalException); - // When - listener.handleTestCaseFinished(testCaseFinished); + // When & Then + ReportTestResultException exception = assertThrows( + ReportTestResultException.class, + () -> listener.handleTestCaseFinished(testCaseFinished) + ); + assertTrue(exception.getMessage().contains("Failed to report test result for: Test Scenario")); + // Verify that afterEach was still called despite the exception + verify(awsService).uploadAllArtifactsForTest("Test Title", testCaseId.toString(), "@T12345"); + } + @Test + void shouldVerifyListenerImplementsCorrectInterfaces() { // Then - verify(resultConstructor, never()).constructTestRunResult(any()); - verify(runManager, never()).reportTest(any()); + assertTrue(listener instanceof io.cucumber.plugin.Plugin); + assertTrue(listener instanceof io.cucumber.plugin.EventListener); } @Test - void shouldThrowExceptionWhenEventIsNull() { + void shouldVerifyEventPublisherRegistration() { // Given - when(runManager.isActive()).thenReturn(true); - - // When & Then - CucumberListenerException exception = assertThrows( - CucumberListenerException.class, - () -> listener.handleTestCaseFinished(null) - ); + listener.setEventPublisher(eventPublisher); - assertEquals("The listener received null event", exception.getMessage()); + // Then + verify(eventPublisher, times(3)).registerHandlerFor(any(), any()); } @Test - void shouldWrapExceptionWhenConstructorFails() { + void shouldCallAfterEachWithCorrectParameters() { // Given - RuntimeException originalException = new RuntimeException("Constructor error"); when(runManager.isActive()).thenReturn(true); + UUID testCaseId = UUID.randomUUID(); when(testCaseFinished.getTestCase()).thenReturn(testCase); when(testCase.getName()).thenReturn("Test Scenario"); - when(resultConstructor.constructTestRunResult(testCaseFinished)) - .thenThrow(originalException); + when(testCase.getId()).thenReturn(testCaseId); + when(resultConstructor.constructTestRunResult(testCaseFinished)).thenReturn(testResult); + when(dataExtractor.extractTitle(testCaseFinished)).thenReturn("Test Title"); + when(dataExtractor.extractTestId(testCaseFinished)).thenReturn("@T12345"); - // When & Then - ReportTestResultException exception = assertThrows( - ReportTestResultException.class, - () -> listener.handleTestCaseFinished(testCaseFinished) - ); + // When + listener.handleTestCaseFinished(testCaseFinished); - assertTrue(exception.getMessage().contains("Failed to report test result for: Test Scenario")); - assertNull(exception.getCause()); // ReportTestResultException doesn't chain the cause properly + // Then + verify(dataExtractor).extractTitle(testCaseFinished); + verify(dataExtractor).extractTestId(testCaseFinished); + verify(awsService).uploadAllArtifactsForTest("Test Title", testCaseId.toString(), "@T12345"); } @Test - void shouldWrapExceptionWhenReportTestFails() { + void shouldHandleNullTestIdInAfterEach() { // Given - RuntimeException originalException = new RuntimeException("Report error"); when(runManager.isActive()).thenReturn(true); + UUID testCaseId = UUID.randomUUID(); when(testCaseFinished.getTestCase()).thenReturn(testCase); when(testCase.getName()).thenReturn("Test Scenario"); + when(testCase.getId()).thenReturn(testCaseId); when(resultConstructor.constructTestRunResult(testCaseFinished)).thenReturn(testResult); - doThrow(originalException).when(runManager).reportTest(testResult); + when(dataExtractor.extractTitle(testCaseFinished)).thenReturn("Test Title"); + when(dataExtractor.extractTestId(testCaseFinished)).thenReturn(null); - // When & Then - ReportTestResultException exception = assertThrows( - ReportTestResultException.class, - () -> listener.handleTestCaseFinished(testCaseFinished) - ); + // When + listener.handleTestCaseFinished(testCaseFinished); - assertTrue(exception.getMessage().contains("Failed to report test result for: Test Scenario")); - assertNull(exception.getCause()); // ReportTestResultException doesn't chain the cause properly + // Then + verify(awsService).uploadAllArtifactsForTest("Test Title", testCaseId.toString(), null); } @Test - void shouldHandleUnknownTestWhenTestCaseIsNull() { + void shouldHandleNullTitleInAfterEach() { // Given when(runManager.isActive()).thenReturn(true); - when(testCaseFinished.getTestCase()).thenReturn(null); - when(resultConstructor.constructTestRunResult(testCaseFinished)) - .thenThrow(new RuntimeException("Constructor error")); + UUID testCaseId = UUID.randomUUID(); + when(testCaseFinished.getTestCase()).thenReturn(testCase); + when(testCase.getName()).thenReturn("Test Scenario"); + when(testCase.getId()).thenReturn(testCaseId); + when(resultConstructor.constructTestRunResult(testCaseFinished)).thenReturn(testResult); + when(dataExtractor.extractTitle(testCaseFinished)).thenReturn(null); + when(dataExtractor.extractTestId(testCaseFinished)).thenReturn("@T12345"); - // When & Then - ReportTestResultException exception = assertThrows( - ReportTestResultException.class, - () -> listener.handleTestCaseFinished(testCaseFinished) - ); + // When + listener.handleTestCaseFinished(testCaseFinished); - assertTrue(exception.getMessage().contains("Failed to report test result for: Unknown Test")); + // Then + verify(awsService).uploadAllArtifactsForTest(null, testCaseId.toString(), "@T12345"); } @Test - void shouldHandleUnknownTestWhenTestNameIsNull() { + void shouldStillCallAfterEachWhenReportTestFails() { // Given when(runManager.isActive()).thenReturn(true); + UUID testCaseId = UUID.randomUUID(); + RuntimeException reportException = new RuntimeException("Report failed"); when(testCaseFinished.getTestCase()).thenReturn(testCase); - when(testCase.getName()).thenReturn(null); - when(resultConstructor.constructTestRunResult(testCaseFinished)) - .thenThrow(new RuntimeException("Constructor error")); + when(testCase.getName()).thenReturn("Test Scenario"); + when(testCase.getId()).thenReturn(testCaseId); + when(resultConstructor.constructTestRunResult(testCaseFinished)).thenReturn(testResult); + when(dataExtractor.extractTitle(testCaseFinished)).thenReturn("Test Title"); + when(dataExtractor.extractTestId(testCaseFinished)).thenReturn("@T12345"); + doThrow(reportException).when(runManager).reportTest(testResult); // When & Then ReportTestResultException exception = assertThrows( @@ -212,23 +243,8 @@ void shouldHandleUnknownTestWhenTestNameIsNull() { () -> listener.handleTestCaseFinished(testCaseFinished) ); - // The actual implementation uses the test name or null if getName() returns null - assertTrue(exception.getMessage().contains("Failed to report test result for: null")); - } - - @Test - void shouldVerifyListenerImplementsCorrectInterfaces() { - // Then - assertTrue(listener instanceof io.cucumber.plugin.Plugin); - assertTrue(listener instanceof io.cucumber.plugin.EventListener); - } - - @Test - void shouldVerifyEventPublisherRegistration() { - // Given - listener.setEventPublisher(eventPublisher); - - // Then - verify(eventPublisher, times(3)).registerHandlerFor(any(), any()); + // Verify that afterEach was still called before the exception was thrown + verify(awsService).uploadAllArtifactsForTest("Test Title", testCaseId.toString(), "@T12345"); + assertTrue(exception.getMessage().contains("Failed to report test result for: Test Scenario")); } } \ No newline at end of file diff --git a/java-reporter-junit/pom.xml b/java-reporter-junit/pom.xml index 9a6fb85..f80c1ab 100644 --- a/java-reporter-junit/pom.xml +++ b/java-reporter-junit/pom.xml @@ -6,7 +6,7 @@ io.testomat java-reporter-junit - 0.7.5 + 0.7.9 jar Testomat.io Java Reporter JUnit @@ -49,7 +49,7 @@ io.testomat java-reporter-core - 0.7.5 + 0.7.9 org.junit.jupiter diff --git a/java-reporter-junit/src/main/java/io/testomat/junit/constructor/JUnitTestResultConstructor.java b/java-reporter-junit/src/main/java/io/testomat/junit/constructor/JUnitTestResultConstructor.java index 6722e12..970f05a 100644 --- a/java-reporter-junit/src/main/java/io/testomat/junit/constructor/JUnitTestResultConstructor.java +++ b/java-reporter-junit/src/main/java/io/testomat/junit/constructor/JUnitTestResultConstructor.java @@ -74,7 +74,8 @@ public TestResult constructTestRunResult(TestMetadata metadata, String stack; Object example = null; - String rid = null; + String rid = context.getUniqueId(); + log.debug("-> RID = {}", rid); if (metaDataExtractor.isParameterizedTest(context)) { example = metaDataExtractor.extractTestParameters(context); diff --git a/java-reporter-junit/src/main/java/io/testomat/junit/extractor/JunitMetaDataExtractor.java b/java-reporter-junit/src/main/java/io/testomat/junit/extractor/JunitMetaDataExtractor.java index 39d198b..aab2be9 100644 --- a/java-reporter-junit/src/main/java/io/testomat/junit/extractor/JunitMetaDataExtractor.java +++ b/java-reporter-junit/src/main/java/io/testomat/junit/extractor/JunitMetaDataExtractor.java @@ -129,7 +129,7 @@ private String extractTitleFromAnnotation(Method method) { return annotation != null ? annotation.value() : method.getName(); } - private String extractTestId(Method method) { + public static String extractTestId(Method method) { TestId annotation = method.getAnnotation(TestId.class); return annotation != null ? annotation.value() : null; } diff --git a/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java b/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java index 4401a58..75728cc 100644 --- a/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java +++ b/java-reporter-junit/src/main/java/io/testomat/junit/listener/JunitListener.java @@ -1,19 +1,23 @@ package io.testomat.junit.listener; +import static io.testomat.core.constants.ArtifactPropertyNames.ARTIFACT_DISABLE_PROPERTY_NAME; import static io.testomat.core.constants.CommonConstants.FAILED; import static io.testomat.core.constants.CommonConstants.PASSED; import static io.testomat.core.constants.CommonConstants.SKIPPED; import static io.testomat.core.constants.PropertyNameConstants.API_KEY_PROPERTY_NAME; +import io.testomat.core.artifact.client.AwsService; import io.testomat.core.propertyconfig.impl.PropertyProviderFactoryImpl; import io.testomat.core.propertyconfig.interf.PropertyProvider; import io.testomat.core.runmanager.GlobalRunManager; +import io.testomat.junit.extractor.JunitMetaDataExtractor; import io.testomat.junit.methodexporter.MethodExportManager; import io.testomat.junit.reporter.JunitTestReporter; import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.AfterEachCallback; import org.junit.jupiter.api.extension.BeforeAllCallback; import org.junit.jupiter.api.extension.BeforeEachCallback; import org.junit.jupiter.api.extension.ExtensionContext; @@ -26,15 +30,16 @@ * Reports JUnit test execution results to Testomat.io platform. */ public class JunitListener implements BeforeEachCallback, BeforeAllCallback, - AfterAllCallback, TestWatcher { + AfterAllCallback, AfterEachCallback, TestWatcher { private static final Logger log = LoggerFactory.getLogger(JunitListener.class); + private boolean artifactDisabled = false; private final MethodExportManager methodExportManager; private final GlobalRunManager runManager; private final JunitTestReporter reporter; private final PropertyProvider provider; - + private final AwsService awsService; private final Set processedClasses; public JunitListener() { @@ -42,8 +47,10 @@ public JunitListener() { this.runManager = GlobalRunManager.getInstance(); this.reporter = new JunitTestReporter(); this.processedClasses = ConcurrentHashMap.newKeySet(); + this.awsService = new AwsService(); this.provider = PropertyProviderFactoryImpl.getPropertyProviderFactory().getPropertyProvider(); + this.artifactDisabled = defineArtifactsDisabled(); } /** @@ -57,12 +64,14 @@ public JunitListener() { public JunitListener(MethodExportManager methodExportManager, GlobalRunManager runManager, JunitTestReporter reporter, - PropertyProvider provider) { + PropertyProvider provider, + AwsService awsService) { this.methodExportManager = methodExportManager; this.runManager = runManager; this.reporter = reporter; this.provider = provider; this.processedClasses = ConcurrentHashMap.newKeySet(); + this.awsService = awsService; } @Override @@ -152,4 +161,27 @@ private boolean isListeningRequired() { return false; } } + + @Override + public void afterEach(ExtensionContext context) { + if (!artifactDisabled) { + awsService.uploadAllArtifactsForTest(context.getDisplayName(), context.getUniqueId(), + JunitMetaDataExtractor.extractTestId(context.getTestMethod().get())); + } + } + + private boolean defineArtifactsDisabled() { + boolean result; + String property; + try { + property = provider.getProperty(ARTIFACT_DISABLE_PROPERTY_NAME); + result = property != null + && !property.trim().isEmpty() + && !property.equalsIgnoreCase("0"); + + } catch (Exception e) { + return false; + } + return result; + } } diff --git a/java-reporter-junit/src/test/java/io/testomat/junit/listener/JunitListenerTest.java b/java-reporter-junit/src/test/java/io/testomat/junit/listener/JunitListenerTest.java index adf3eef..5d62576 100644 --- a/java-reporter-junit/src/test/java/io/testomat/junit/listener/JunitListenerTest.java +++ b/java-reporter-junit/src/test/java/io/testomat/junit/listener/JunitListenerTest.java @@ -1,639 +1,371 @@ package io.testomat.junit.listener; +import static io.testomat.core.constants.ArtifactPropertyNames.ARTIFACT_DISABLE_PROPERTY_NAME; import static io.testomat.core.constants.CommonConstants.FAILED; import static io.testomat.core.constants.CommonConstants.PASSED; import static io.testomat.core.constants.CommonConstants.SKIPPED; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static io.testomat.core.constants.PropertyNameConstants.API_KEY_PROPERTY_NAME; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; -import io.testomat.core.exception.PropertyNotFoundException; +import io.testomat.core.artifact.client.AwsService; import io.testomat.core.propertyconfig.interf.PropertyProvider; import io.testomat.core.runmanager.GlobalRunManager; import io.testomat.junit.methodexporter.MethodExportManager; import io.testomat.junit.reporter.JunitTestReporter; +import java.lang.reflect.Method; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtensionContext; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -@DisplayName("JunitListener Tests (Updated Version)") class JunitListenerTest { @Mock - private MethodExportManager mockMethodExportManager; + private MethodExportManager methodExportManager; @Mock - private GlobalRunManager mockRunManager; + private GlobalRunManager runManager; @Mock - private JunitTestReporter mockReporter; + private JunitTestReporter reporter; @Mock - private PropertyProvider mockPropertyProvider; + private PropertyProvider propertyProvider; @Mock - private ExtensionContext mockExtensionContext; + private AwsService awsService; - private JunitListener junitListener; + @Mock + private ExtensionContext context; - // Test classes for testing different scenarios - public static class TestClassA { - } + @Mock + private Method testMethod; - public static class TestClassB { - } + private JunitListener listener; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); - - // Setup mock context with a valid unique ID to avoid NPE in cleanup methods - when(mockExtensionContext.getUniqueId()).thenReturn("test-unique-id-123"); - - junitListener = new JunitListener( - mockMethodExportManager, - mockRunManager, - mockReporter, - mockPropertyProvider - ); + listener = new JunitListener(methodExportManager, runManager, reporter, propertyProvider, awsService); } - @Nested - @DisplayName("Constructor Tests") - class ConstructorTests { - - @Test - @DisplayName("Should create instance with default constructor") - void shouldCreateInstanceWithDefaultConstructor() { - // When - JunitListener listener = new JunitListener(); - - // Then - assertNotNull(listener); - } - - @Test - @DisplayName("Should create instance with parameterized constructor") - void shouldCreateInstanceWithParameterizedConstructor() { - // When - JunitListener listener = new JunitListener( - mockMethodExportManager, - mockRunManager, - mockReporter, - mockPropertyProvider - ); - - // Then - assertNotNull(listener); - } + @Test + void beforeAll_WhenListeningNotRequired_ShouldSkipIncrement() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn(null); + + listener.beforeAll(context); + + verify(runManager, never()).incrementSuiteCounter(); } - @Nested - @DisplayName("Listening Required Logic Tests") - class ListeningRequiredLogicTests { - - @Test - @DisplayName("Should return false when property throws PropertyNotFoundException") - void shouldReturnFalseWhenPropertyThrowsPropertyNotFoundException() { - // Given - when(mockPropertyProvider.getProperty("testomatio")) - .thenThrow(new PropertyNotFoundException("Property not found")); - - // When - junitListener.beforeAll(mockExtensionContext); - - // Then - should do early return, not call runManager - verify(mockRunManager, never()).incrementSuiteCounter(); - } - - @Test - @DisplayName("Should return false when property throws RuntimeException") - void shouldReturnFalseWhenPropertyThrowsRuntimeException() { - // Given - when(mockPropertyProvider.getProperty("testomatio")) - .thenThrow(new RuntimeException("Unexpected error")); - - // When - junitListener.beforeAll(mockExtensionContext); - - // Then - should do early return, not call runManager - verify(mockRunManager, never()).incrementSuiteCounter(); - } - - @Test - @DisplayName("Should return false when property returns null") - void shouldReturnFalseWhenPropertyReturnsNull() { - // Given - when(mockPropertyProvider.getProperty("testomatio")).thenReturn(null); - - // When - junitListener.beforeAll(mockExtensionContext); - - // Then - should do early return, not call runManager - verify(mockRunManager, never()).incrementSuiteCounter(); - } + @Test + void beforeAll_WhenListeningRequired_ShouldIncrementSuiteCounter() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn("test-api-key"); + + listener.beforeAll(context); + + verify(runManager).incrementSuiteCounter(); } - @Nested - @DisplayName("BeforeAll Tests") - class BeforeAllTests { - - @Test - @DisplayName("Should increment suite counter when listening is required") - void shouldIncrementSuiteCounterWhenListeningIsRequired() { - // Given - when(mockPropertyProvider.getProperty("testomatio")).thenReturn("true"); - - // When - junitListener.beforeAll(mockExtensionContext); - - // Then - verify(mockRunManager, times(1)).incrementSuiteCounter(); - } - - @Test - @DisplayName("Should not increment suite counter when listening is not required") - void shouldNotIncrementSuiteCounterWhenListeningIsNotRequired() { - // Given - when(mockPropertyProvider.getProperty("testomatio")).thenReturn(null); - - // When - junitListener.beforeAll(mockExtensionContext); - - // Then - verify(mockRunManager, never()).incrementSuiteCounter(); - } - - @Test - @DisplayName("Should handle multiple beforeAll calls when listening is required") - void shouldHandleMultipleBeforeAllCallsWhenListeningIsRequired() { - // Given - when(mockPropertyProvider.getProperty("testomatio")).thenReturn("enabled"); - - // When - junitListener.beforeAll(mockExtensionContext); - junitListener.beforeAll(mockExtensionContext); - junitListener.beforeAll(mockExtensionContext); - - // Then - verify(mockRunManager, times(3)).incrementSuiteCounter(); - } + @Test + void beforeAll_WhenPropertyProviderThrowsException_ShouldSkipIncrement() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenThrow(new RuntimeException("Property error")); + + listener.beforeAll(context); + + verify(runManager, never()).incrementSuiteCounter(); } - @Nested - @DisplayName("AfterAll Tests") - class AfterAllTests { - - @Test - @DisplayName("Should export test class and decrement suite counter when listening is required") - void shouldExportTestClassAndDecrementSuiteCounterWhenListeningIsRequired() { - // Given - when(mockPropertyProvider.getProperty("testomatio")).thenReturn("true"); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); - - // When - junitListener.afterAll(mockExtensionContext); - - // Then - verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); - verify(mockRunManager, times(1)).decrementSuiteCounter(); - } - - @Test - @DisplayName("Should not export or decrement when listening is not required") - void shouldNotExportOrDecrementWhenListeningIsNotRequired() { - // Given - when(mockPropertyProvider.getProperty("testomatio")).thenReturn(null); - - // When - junitListener.afterAll(mockExtensionContext); - - // Then - verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); - verify(mockRunManager, never()).decrementSuiteCounter(); - } - - @Test - @DisplayName("Should only decrement suite counter when no test class present") - void shouldOnlyDecrementSuiteCounterWhenNoTestClassPresent() { - // Given - when(mockPropertyProvider.getProperty("testomatio")).thenReturn("true"); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.empty()); - - // When - junitListener.afterAll(mockExtensionContext); - - // Then - verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); - verify(mockRunManager, times(1)).decrementSuiteCounter(); - } + @Test + void afterAll_WhenListeningNotRequired_ShouldSkipProcessing() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn(null); + + listener.afterAll(context); + + verify(runManager, never()).decrementSuiteCounter(); + verifyNoInteractions(methodExportManager); } - @Nested - @DisplayName("BeforeEach Tests") - class BeforeEachTests { - - @Test - @DisplayName("Should not perform any operations in beforeEach regardless of listening status") - void shouldNotPerformAnyOperationsInBeforeEach() { - // Given - when(mockPropertyProvider.getProperty("testomatio")).thenReturn("true"); - - // When - junitListener.beforeEach(mockExtensionContext); - - // Then - verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); - verify(mockRunManager, never()).incrementSuiteCounter(); - verify(mockRunManager, never()).decrementSuiteCounter(); - verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); - } + @Test + void afterAll_WhenListeningRequired_ShouldDecrementCounterAndExportClass() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn("test-api-key"); + when(context.getTestClass()).thenReturn(Optional.of(String.class)); + + listener.afterAll(context); + + verify(runManager).decrementSuiteCounter(); + verify(methodExportManager).loadTestBodyForClass(String.class); } - @Nested - @DisplayName("Test Disabled Tests") - class TestDisabledTests { - - @Test - @DisplayName("Should report test as disabled when listening is required") - void shouldReportTestAsDisabledWhenListeningIsRequired() { - // Given - String reason = "Test disabled for maintenance"; - when(mockPropertyProvider.getProperty("testomatio")).thenReturn("true"); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); - - // When - junitListener.testDisabled(mockExtensionContext, Optional.of(reason)); - - // Then - verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, SKIPPED, reason); - verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); - } - - @Test - @DisplayName("Should not report when listening is not required") - void shouldNotReportWhenListeningIsNotRequired() { - // Given - String reason = "Test disabled"; - when(mockPropertyProvider.getProperty("testomatio")).thenReturn(null); - - // When - junitListener.testDisabled(mockExtensionContext, Optional.of(reason)); - - // Then - verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); - verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); - } - - @Test - @DisplayName("Should use default message when no reason provided") - void shouldUseDefaultMessageWhenNoReasonProvided() { - // Given - when(mockPropertyProvider.getProperty("testomatio")).thenReturn("true"); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); - - // When - junitListener.testDisabled(mockExtensionContext, Optional.empty()); - - // Then - verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, SKIPPED, "Test disabled"); - verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); - } + @Test + void afterAll_WhenTestClassEmpty_ShouldDecrementCounterOnly() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn("test-api-key"); + when(context.getTestClass()).thenReturn(Optional.empty()); + + listener.afterAll(context); + + verify(runManager).decrementSuiteCounter(); + verify(methodExportManager, never()).loadTestBodyForClass(any()); } - @Nested - @DisplayName("Test Successful Tests") - class TestSuccessfulTests { - - @Test - @DisplayName("Should report test as successful when listening is required") - void shouldReportTestAsSuccessfulWhenListeningIsRequired() { - // Given - when(mockPropertyProvider.getProperty("testomatio")).thenReturn("true"); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); - - // When - junitListener.testSuccessful(mockExtensionContext); - - // Then - verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, PASSED, null); - verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); - } - - @Test - @DisplayName("Should not report when listening is not required") - void shouldNotReportWhenListeningIsNotRequired() { - // Given - when(mockPropertyProvider.getProperty("testomatio")).thenReturn(null); - - // When - junitListener.testSuccessful(mockExtensionContext); - - // Then - verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); - verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); - } + @Test + void beforeEach_ShouldNotPerformAnyActions() { + assertDoesNotThrow(() -> listener.beforeEach(context)); + verifyNoInteractions(runManager, reporter, methodExportManager, awsService); } - @Nested - @DisplayName("Test Aborted Tests") - class TestAbortedTests { - - @Test - @DisplayName("Should report test as aborted when listening is required") - void shouldReportTestAsAbortedWhenListeningIsRequired() { - // Given - Throwable cause = new RuntimeException("Test was aborted"); - when(mockPropertyProvider.getProperty("testomatio")).thenReturn("true"); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); - - // When - junitListener.testAborted(mockExtensionContext, cause); - - // Then - verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, SKIPPED, "Test was aborted"); - verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); - } - - @Test - @DisplayName("Should not report when listening is not required") - void shouldNotReportWhenListeningIsNotRequired() { - // Given - Throwable cause = new RuntimeException("Test was aborted"); - when(mockPropertyProvider.getProperty("testomatio")).thenReturn(null); - - // When - junitListener.testAborted(mockExtensionContext, cause); - - // Then - verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); - verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); - } - - @Test - @DisplayName("Should handle null cause message") - void shouldHandleNullCauseMessage() { - // Given - Throwable cause = new RuntimeException(); // No message - when(mockPropertyProvider.getProperty("testomatio")).thenReturn("true"); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); - - // When - junitListener.testAborted(mockExtensionContext, cause); - - // Then - verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, SKIPPED, null); - verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); - } + @Test + void testDisabled_WhenListeningNotRequired_ShouldSkipReporting() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn(null); + + listener.testDisabled(context, Optional.of("Test disabled")); + + verifyNoInteractions(reporter, methodExportManager); } - @Nested - @DisplayName("Test Failed Tests") - class TestFailedTests { - - @Test - @DisplayName("Should report test as failed when listening is required") - void shouldReportTestAsFailedWhenListeningIsRequired() { - // Given - Throwable cause = new AssertionError("Expected <5> but was <3>"); - when(mockPropertyProvider.getProperty("testomatio")).thenReturn("true"); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); - - // When - junitListener.testFailed(mockExtensionContext, cause); - - // Then - verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, FAILED, "Expected <5> but was <3>"); - verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); - } - - @Test - @DisplayName("Should not report when listening is not required") - void shouldNotReportWhenListeningIsNotRequired() { - // Given - Throwable cause = new AssertionError("Test failed"); - when(mockPropertyProvider.getProperty("testomatio")).thenReturn(null); - - // When - junitListener.testFailed(mockExtensionContext, cause); - - // Then - verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); - verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); - } - - @Test - @DisplayName("Should handle null cause message") - void shouldHandleNullCauseMessage() { - // Given - Throwable cause = new AssertionError(); // No message - when(mockPropertyProvider.getProperty("testomatio")).thenReturn("true"); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); - - // When - junitListener.testFailed(mockExtensionContext, cause); - - // Then - verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, FAILED, null); - verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); - } + @Test + void testDisabled_WhenListeningRequired_ShouldReportSkippedWithReason() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn("test-api-key"); + when(context.getTestClass()).thenReturn(Optional.of(String.class)); + + listener.testDisabled(context, Optional.of("Test disabled")); + + verify(reporter).reportTestResult(context, SKIPPED, "Test disabled"); + verify(methodExportManager).loadTestBodyForClass(String.class); } - @Nested - @DisplayName("Export Test Class Tests") - class ExportTestClassTests { - - @Test - @DisplayName("Should export different test classes separately") - void shouldExportDifferentTestClassesSeparately() { - // Given - ExtensionContext contextA = org.mockito.Mockito.mock(ExtensionContext.class); - ExtensionContext contextB = org.mockito.Mockito.mock(ExtensionContext.class); - - when(mockPropertyProvider.getProperty("testomatio")).thenReturn("true"); - when(contextA.getTestClass()).thenReturn(Optional.of(TestClassA.class)); - when(contextA.getUniqueId()).thenReturn("test-context-A"); - when(contextB.getTestClass()).thenReturn(Optional.of(TestClassB.class)); - when(contextB.getUniqueId()).thenReturn("test-context-B"); - - // When - junitListener.testSuccessful(contextA); - junitListener.testSuccessful(contextB); - junitListener.testSuccessful(contextA); // Same class again - - // Then - should export each class once - verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); - verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassB.class); - } - - @Test - @DisplayName("Should not export when listening is not required") - void shouldNotExportWhenListeningIsNotRequired() { - // Given - when(mockPropertyProvider.getProperty("testomatio")).thenReturn(null); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); - - // When - junitListener.testSuccessful(mockExtensionContext); - junitListener.testFailed(mockExtensionContext, new RuntimeException("Error")); - - // Then - verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); - } - - @Test - @DisplayName("Should not export when test class is not present") - void shouldNotExportWhenTestClassIsNotPresent() { - // Given - when(mockPropertyProvider.getProperty("testomatio")).thenReturn("true"); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.empty()); - - // When - junitListener.testSuccessful(mockExtensionContext); - - // Then - verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); - } + @Test + void testDisabled_WhenReasonEmpty_ShouldReportSkippedWithDefaultMessage() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn("test-api-key"); + when(context.getTestClass()).thenReturn(Optional.of(String.class)); + + listener.testDisabled(context, Optional.empty()); + + verify(reporter).reportTestResult(context, SKIPPED, "Test disabled"); + verify(methodExportManager).loadTestBodyForClass(String.class); } - @Nested - @DisplayName("Property Provider Exception Handling Tests") - class PropertyProviderExceptionHandlingTests { - - @Test - @DisplayName("Should handle PropertyNotFoundException gracefully in all callbacks") - void shouldHandlePropertyNotFoundExceptionGracefullyInAllCallbacks() { - // Given - when(mockPropertyProvider.getProperty("testomatio")) - .thenThrow(new PropertyNotFoundException("Property not found")); - - // When & Then - all callbacks should handle exception gracefully - junitListener.beforeAll(mockExtensionContext); - verify(mockRunManager, never()).incrementSuiteCounter(); - - junitListener.afterAll(mockExtensionContext); - verify(mockRunManager, never()).decrementSuiteCounter(); - verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); - - junitListener.testSuccessful(mockExtensionContext); - verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); - - junitListener.testFailed(mockExtensionContext, new RuntimeException()); - verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); - - junitListener.testDisabled(mockExtensionContext, Optional.of("disabled")); - verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); - - junitListener.testAborted(mockExtensionContext, new RuntimeException()); - verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); - } - - @Test - @DisplayName("Should handle unexpected RuntimeException in property access") - void shouldHandleUnexpectedRuntimeExceptionInPropertyAccess() { - // Given - when(mockPropertyProvider.getProperty("testomatio")) - .thenThrow(new RuntimeException("Unexpected property access error")); - - // When & Then - should not propagate exception - junitListener.beforeAll(mockExtensionContext); - verify(mockRunManager, never()).incrementSuiteCounter(); - - junitListener.testSuccessful(mockExtensionContext); - verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); - } - - @Test - @DisplayName("Should handle intermittent property provider failures") - void shouldHandleIntermittentPropertyProviderFailures() { - // Given - first call fails, second succeeds - when(mockPropertyProvider.getProperty("testomatio")) - .thenThrow(new RuntimeException("Temporary failure")) - .thenReturn("true"); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); - - // When - junitListener.testSuccessful(mockExtensionContext); // Should fail and not report - junitListener.testSuccessful(mockExtensionContext); // Should succeed and report - - // Then - verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, PASSED, null); - verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); - } + @Test + void testSuccessful_WhenListeningNotRequired_ShouldSkipReporting() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn(null); + + listener.testSuccessful(context); + + verifyNoInteractions(reporter, methodExportManager); } - @Nested - @DisplayName("Integration Tests") - class IntegrationTests { - - @Test - @DisplayName("Should handle complete test lifecycle when listening is required") - void shouldHandleCompleteTestLifecycleWhenListeningIsRequired() { - // Given - when(mockPropertyProvider.getProperty("testomatio")).thenReturn("enabled"); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); - - // When - simulate complete test lifecycle - junitListener.beforeAll(mockExtensionContext); - junitListener.beforeEach(mockExtensionContext); - junitListener.testSuccessful(mockExtensionContext); - junitListener.beforeEach(mockExtensionContext); - junitListener.testFailed(mockExtensionContext, new RuntimeException("Test failed")); - junitListener.afterAll(mockExtensionContext); - - // Then - verify(mockRunManager, times(1)).incrementSuiteCounter(); - verify(mockRunManager, times(1)).decrementSuiteCounter(); - verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, PASSED, null); - verify(mockReporter, times(1)).reportTestResult(mockExtensionContext, FAILED, "Test failed"); - verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); - } - - @Test - @DisplayName("Should handle complete test lifecycle when listening is not required") - void shouldHandleCompleteTestLifecycleWhenListeningIsNotRequired() { - // Given - when(mockPropertyProvider.getProperty("testomatio")).thenReturn(null); - - // When - simulate complete test lifecycle - junitListener.beforeAll(mockExtensionContext); - junitListener.beforeEach(mockExtensionContext); - junitListener.testSuccessful(mockExtensionContext); - junitListener.testFailed(mockExtensionContext, new RuntimeException("Test failed")); - junitListener.afterAll(mockExtensionContext); - - // Then - nothing should be called - verify(mockRunManager, never()).incrementSuiteCounter(); - verify(mockRunManager, never()).decrementSuiteCounter(); - verify(mockReporter, never()).reportTestResult(any(), anyString(), anyString()); - verify(mockMethodExportManager, never()).loadTestBodyForClass(any()); - } - - @Test - @DisplayName("Should be thread-safe for concurrent test execution") - void shouldBeThreadSafeForConcurrentTestExecution() { - // Given - when(mockPropertyProvider.getProperty("testomatio")).thenReturn("true"); - when(mockExtensionContext.getTestClass()).thenReturn(Optional.of(TestClassA.class)); - - // When - simulate concurrent test execution - junitListener.testSuccessful(mockExtensionContext); - junitListener.testSuccessful(mockExtensionContext); - junitListener.testSuccessful(mockExtensionContext); - - // Then - should handle concurrent access to processedClasses set - verify(mockReporter, times(3)).reportTestResult(mockExtensionContext, PASSED, null); - verify(mockMethodExportManager, times(1)).loadTestBodyForClass(TestClassA.class); - } + @Test + void testSuccessful_WhenListeningRequired_ShouldReportPassed() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn("test-api-key"); + when(context.getTestClass()).thenReturn(Optional.of(String.class)); + + listener.testSuccessful(context); + + verify(reporter).reportTestResult(context, PASSED, null); + verify(methodExportManager).loadTestBodyForClass(String.class); + } + + @Test + void testAborted_WhenListeningNotRequired_ShouldSkipReporting() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn(null); + Throwable cause = new RuntimeException("Test aborted"); + + listener.testAborted(context, cause); + + verifyNoInteractions(reporter, methodExportManager); + } + + @Test + void testAborted_WhenListeningRequired_ShouldReportSkippedWithCauseMessage() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn("test-api-key"); + when(context.getTestClass()).thenReturn(Optional.of(String.class)); + Throwable cause = new RuntimeException("Test aborted"); + + listener.testAborted(context, cause); + + verify(reporter).reportTestResult(context, SKIPPED, "Test aborted"); + verify(methodExportManager).loadTestBodyForClass(String.class); + } + + @Test + void testFailed_WhenListeningNotRequired_ShouldSkipReporting() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn(null); + Throwable cause = new AssertionError("Test failed"); + + listener.testFailed(context, cause); + + verifyNoInteractions(reporter, methodExportManager); + } + + @Test + void testFailed_WhenListeningRequired_ShouldReportFailedWithCauseMessage() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn("test-api-key"); + when(context.getTestClass()).thenReturn(Optional.of(String.class)); + Throwable cause = new AssertionError("Test failed"); + + listener.testFailed(context, cause); + + verify(reporter).reportTestResult(context, FAILED, "Test failed"); + verify(methodExportManager).loadTestBodyForClass(String.class); + } + + @Test + void exportTestClassIfNotProcessed_WhenSameClassCalledMultipleTimes_ShouldExportOnlyOnce() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn("test-api-key"); + when(context.getTestClass()).thenReturn(Optional.of(String.class)); + + listener.testSuccessful(context); + listener.testSuccessful(context); + listener.testFailed(context, new Exception("Failed")); + + verify(methodExportManager, times(1)).loadTestBodyForClass(String.class); + } + + @Test + void afterEach_WhenArtifactsEnabled_ShouldUploadArtifacts() { + when(context.getDisplayName()).thenReturn("testName"); + when(context.getUniqueId()).thenReturn("uniqueId"); + when(context.getTestMethod()).thenReturn(Optional.of(testMethod)); + + listener.afterEach(context); + + verify(awsService).uploadAllArtifactsForTest(eq("testName"), eq("uniqueId"), any()); + } + + @Test + void defineArtifactsDisabled_WhenPropertyIsNull_ShouldReturnFalse() { + when(propertyProvider.getProperty(ARTIFACT_DISABLE_PROPERTY_NAME)).thenReturn(null); + + JunitListener testListener = new JunitListener(methodExportManager, runManager, reporter, propertyProvider, awsService); + + when(context.getDisplayName()).thenReturn("testName"); + when(context.getUniqueId()).thenReturn("uniqueId"); + when(context.getTestMethod()).thenReturn(Optional.of(testMethod)); + + testListener.afterEach(context); + + verify(awsService).uploadAllArtifactsForTest(anyString(), anyString(), any()); + } + + @Test + void defineArtifactsDisabled_WhenPropertyIsEmpty_ShouldReturnFalse() { + when(propertyProvider.getProperty(ARTIFACT_DISABLE_PROPERTY_NAME)).thenReturn(""); + + JunitListener testListener = new JunitListener(methodExportManager, runManager, reporter, propertyProvider, awsService); + + when(context.getDisplayName()).thenReturn("testName"); + when(context.getUniqueId()).thenReturn("uniqueId"); + when(context.getTestMethod()).thenReturn(Optional.of(testMethod)); + + testListener.afterEach(context); + + verify(awsService).uploadAllArtifactsForTest(anyString(), anyString(), any()); + } + + @Test + void defineArtifactsDisabled_WhenPropertyIsZero_ShouldReturnFalse() { + when(propertyProvider.getProperty(ARTIFACT_DISABLE_PROPERTY_NAME)).thenReturn("0"); + + JunitListener testListener = new JunitListener(methodExportManager, runManager, reporter, propertyProvider, awsService); + + when(context.getDisplayName()).thenReturn("testName"); + when(context.getUniqueId()).thenReturn("uniqueId"); + when(context.getTestMethod()).thenReturn(Optional.of(testMethod)); + + testListener.afterEach(context); + + verify(awsService).uploadAllArtifactsForTest(anyString(), anyString(), any()); + } + + @Test + void defineArtifactsDisabled_WhenPropertyIsTrue_ShouldReturnTrue() { + when(propertyProvider.getProperty(ARTIFACT_DISABLE_PROPERTY_NAME)).thenReturn("true"); + + JunitListener testListener = new JunitListener(methodExportManager, runManager, reporter, propertyProvider, awsService); + + when(context.getDisplayName()).thenReturn("testName"); + when(context.getUniqueId()).thenReturn("uniqueId"); + when(context.getTestMethod()).thenReturn(Optional.of(testMethod)); + + testListener.afterEach(context); + + verify(awsService).uploadAllArtifactsForTest(anyString(), anyString(), any()); + } + + @Test + void defineArtifactsDisabled_WhenPropertyIsAnyNonZeroValue_ShouldReturnTrue() { + when(propertyProvider.getProperty(ARTIFACT_DISABLE_PROPERTY_NAME)).thenReturn("disable"); + + JunitListener testListener = new JunitListener(methodExportManager, runManager, reporter, propertyProvider, awsService); + + when(context.getDisplayName()).thenReturn("testName"); + when(context.getUniqueId()).thenReturn("uniqueId"); + when(context.getTestMethod()).thenReturn(Optional.of(testMethod)); + + testListener.afterEach(context); + + verify(awsService).uploadAllArtifactsForTest(anyString(), anyString(), any()); + } + + @Test + void defineArtifactsDisabled_WhenPropertyProviderThrowsException_ShouldDefaultToFalse() { + when(propertyProvider.getProperty(ARTIFACT_DISABLE_PROPERTY_NAME)).thenThrow(new RuntimeException("Property error")); + + JunitListener testListener = new JunitListener(methodExportManager, runManager, reporter, propertyProvider, awsService); + + when(context.getDisplayName()).thenReturn("testName"); + when(context.getUniqueId()).thenReturn("uniqueId"); + when(context.getTestMethod()).thenReturn(Optional.of(testMethod)); + + testListener.afterEach(context); + + verify(awsService).uploadAllArtifactsForTest(anyString(), anyString(), any()); + } + + @Test + void defaultConstructor_ShouldInitializeAllDependencies() { + JunitListener defaultListener = new JunitListener(); + + assertDoesNotThrow(() -> { + when(context.getTestClass()).thenReturn(Optional.of(String.class)); + defaultListener.testSuccessful(context); + }); + } + + @Test + void testMethodReporting_WhenReporterThrowsException_ShouldPropagateException() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn("test-api-key"); + when(context.getTestClass()).thenReturn(Optional.of(String.class)); + doThrow(new RuntimeException("Reporter error")).when(reporter).reportTestResult(any(), any(), any()); + + RuntimeException exception = org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, + () -> listener.testSuccessful(context)); + + org.junit.jupiter.api.Assertions.assertEquals("Reporter error", exception.getMessage()); + } + + @Test + void testMethodExport_WhenMethodExportManagerThrowsException_ShouldPropagateException() { + when(propertyProvider.getProperty(API_KEY_PROPERTY_NAME)).thenReturn("test-api-key"); + when(context.getTestClass()).thenReturn(Optional.of(String.class)); + doThrow(new RuntimeException("Export error")).when(methodExportManager).loadTestBodyForClass(any()); + + RuntimeException exception = org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, + () -> listener.testSuccessful(context)); + + org.junit.jupiter.api.Assertions.assertEquals("Export error", exception.getMessage()); + verify(reporter).reportTestResult(context, PASSED, null); } } \ No newline at end of file diff --git a/java-reporter-testng/pom.xml b/java-reporter-testng/pom.xml index bf8b68c..7fdca92 100644 --- a/java-reporter-testng/pom.xml +++ b/java-reporter-testng/pom.xml @@ -4,9 +4,9 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - test + io.testomat java-reporter-testng - test + 0.7.9 jar Testomat.io Java Reporter TestNG @@ -46,7 +46,7 @@ io.testomat java-reporter-core - 0.7.5 + 0.7.9 org.testng diff --git a/java-reporter-testng/src/main/java/io/testomat/testng/extractor/TestNgMetaDataExtractor.java b/java-reporter-testng/src/main/java/io/testomat/testng/extractor/TestNgMetaDataExtractor.java index d4eb506..afce8a4 100644 --- a/java-reporter-testng/src/main/java/io/testomat/testng/extractor/TestNgMetaDataExtractor.java +++ b/java-reporter-testng/src/main/java/io/testomat/testng/extractor/TestNgMetaDataExtractor.java @@ -55,7 +55,7 @@ private String getFilePath(Class testClass) { /** * Gets test ID from @TestId annotation. */ - private String getTestId(Method method) { + public String getTestId(Method method) { TestId testIdAnnotation = method.getAnnotation(TestId.class); return testIdAnnotation != null ? testIdAnnotation.value() : null; } diff --git a/java-reporter-testng/src/main/java/io/testomat/testng/extractor/TestNgParameterExtractor.java b/java-reporter-testng/src/main/java/io/testomat/testng/extractor/TestNgParameterExtractor.java index 3255a13..6f90edb 100644 --- a/java-reporter-testng/src/main/java/io/testomat/testng/extractor/TestNgParameterExtractor.java +++ b/java-reporter-testng/src/main/java/io/testomat/testng/extractor/TestNgParameterExtractor.java @@ -122,24 +122,27 @@ private String[] getParameterNamesFromAnnotation(Method method) { } /** - * Generates unique RID (Run ID) for parameterized test based on parameters. + * Generates unique RID (Run ID) for test based on class name, method name and parameters. * Uses readable parameter values when possible. + * For non-parameterized tests, returns className.methodName. * * @param testResult TestNG test result containing parameters - * @return unique RID string or null if no parameters + * @return unique RID string (never null) */ public String generateRid(ITestResult testResult) { + StringBuilder ridBuilder = new StringBuilder(); + ridBuilder.append(testResult.getTestClass().getName()) + .append(".") + .append(testResult.getMethod().getMethodName()); + Object[] parameters = testResult.getParameters(); if (parameters == null || parameters.length == 0) { - return null; + return ridBuilder.toString(); } Method method = testResult.getMethod().getConstructorOrMethod().getMethod(); String[] parameterNames = extractParameterNames(method, parameters.length); - StringBuilder ridBuilder = new StringBuilder(); - ridBuilder.append(testResult.getMethod().getMethodName()); - for (int i = 0; i < parameters.length; i++) { Object param = parameters[i]; String paramString = param != null ? param.toString() : "null"; diff --git a/java-reporter-testng/src/main/java/io/testomat/testng/listener/TestNgListener.java b/java-reporter-testng/src/main/java/io/testomat/testng/listener/TestNgListener.java index 50f5b0d..40f2085 100644 --- a/java-reporter-testng/src/main/java/io/testomat/testng/listener/TestNgListener.java +++ b/java-reporter-testng/src/main/java/io/testomat/testng/listener/TestNgListener.java @@ -1,12 +1,16 @@ package io.testomat.testng.listener; +import static io.testomat.core.constants.ArtifactPropertyNames.ARTIFACT_DISABLE_PROPERTY_NAME; import static io.testomat.core.constants.CommonConstants.FAILED; import static io.testomat.core.constants.CommonConstants.PASSED; import static io.testomat.core.constants.CommonConstants.SKIPPED; +import io.testomat.core.artifact.client.AwsService; import io.testomat.core.propertyconfig.impl.PropertyProviderFactoryImpl; import io.testomat.core.propertyconfig.interf.PropertyProvider; import io.testomat.core.runmanager.GlobalRunManager; +import io.testomat.testng.extractor.TestNgMetaDataExtractor; +import io.testomat.testng.extractor.TestNgParameterExtractor; import io.testomat.testng.filter.TestIdFilter; import io.testomat.testng.methodexporter.TestNgMethodExportManager; import io.testomat.testng.reporter.TestNgTestResultReporter; @@ -40,7 +44,13 @@ public class TestNgListener implements ISuiteListener, ITestListener, private final Set processedClasses; private final TestIdFilter testIdFilter; + private final AwsService awsService; + private final TestNgParameterExtractor testNgParameterExtractor; + private final TestNgMetaDataExtractor metaDataExtractor; + public TestNgListener() { + this.metaDataExtractor = new TestNgMetaDataExtractor(); + this.testNgParameterExtractor = new TestNgParameterExtractor(); this.methodExportManager = new TestNgMethodExportManager(); this.processedClasses = ConcurrentHashMap.newKeySet(); this.runManager = GlobalRunManager.getInstance(); @@ -48,6 +58,7 @@ public TestNgListener() { this.provider = PropertyProviderFactoryImpl.getPropertyProviderFactory().getPropertyProvider(); this.testIdFilter = new TestIdFilter(); + this.awsService = new AwsService(); } /** @@ -56,13 +67,20 @@ public TestNgListener() { public TestNgListener(TestNgMethodExportManager methodExportManager, TestNgTestResultReporter reporter, GlobalRunManager runManager, - PropertyProvider provider) { + PropertyProvider provider, + AwsService awsService, + TestIdFilter testIdFilter, + TestNgParameterExtractor testNgParameterExtractor, + TestNgMetaDataExtractor metaDataExtractor) { this.runManager = runManager; this.reporter = reporter; this.methodExportManager = methodExportManager; this.provider = provider; + this.testNgParameterExtractor = testNgParameterExtractor; + this.metaDataExtractor = metaDataExtractor; this.processedClasses = ConcurrentHashMap.newKeySet(); - this.testIdFilter = new TestIdFilter(); + this.testIdFilter = testIdFilter; + this.awsService = awsService; } @Override @@ -156,7 +174,13 @@ private void exportTestClassIfNotProcessed(Class testClass) { @Override public void afterInvocation(IInvokedMethod method, ITestResult testResult) { - + if (method.isTestMethod() && !defineArtifactsDisabled()) { + awsService.uploadAllArtifactsForTest(testResult.getName(), + testNgParameterExtractor.generateRid(testResult), + metaDataExtractor.getTestId( + method.getTestMethod().getConstructorOrMethod().getMethod()) + ); + } } private boolean isListeningRequired() { @@ -166,4 +190,19 @@ private boolean isListeningRequired() { return false; } } + + private boolean defineArtifactsDisabled() { + boolean result; + String property; + try { + property = provider.getProperty(ARTIFACT_DISABLE_PROPERTY_NAME); + result = property != null + && !property.trim().isEmpty() + && !property.equalsIgnoreCase("0"); + + } catch (Exception e) { + return false; + } + return result; + } }