diff --git a/.gitignore b/.gitignore index 528061d9..d69f3486 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ *.ear *.sw? *.classpath +.claude .gh-pages .idea .pmd diff --git a/CHANGELOG.md b/CHANGELOG.md index b2187fec..f500a869 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,10 +59,27 @@ CHANGELOG * Added the input `/payment/method`. This is the payment method associated with the transaction. You may provide this using the `method` method on `Payment.Builder`. - -3.9.0 ------------------- - +* Added new email domain fields to the `EmailDomain` response model: + * `classification` - A classification of the email domain. Possible values + are `BUSINESS`, `EDUCATION`, `GOVERNMENT`, and `ISP_EMAIL`. + * `risk` - A risk score associated with the email domain, ranging from 0.01 + to 99. Higher scores indicate higher risk. + * `volume` - The activity on the email domain across the minFraud network, + expressed in sightings per million. This value ranges from 0.001 to + 1,000,000. + * `visit` - An `EmailDomainVisit` object containing information about an + automated visit to the email domain, including: + * `status` - The status of the domain based on the automated visit. + Possible values are `LIVE`, `DNS_ERROR`, `NETWORK_ERROR`, `HTTP_ERROR`, + `PARKED`, and `PRE_DEVELOPMENT`. + * `lastVisitedOn` - The date when the automated visit was last completed. + * `hasRedirect` - Whether the domain redirects to another URL. +* Added support for forward-compatible enum deserialization. Enums in response + models will now return `null` for unknown values instead of throwing an + exception. This allows the client to handle new enum values added by the + server without requiring an immediate client update. This required adding + `READ_ENUMS_USING_TO_STRING` and `READ_UNKNOWN_ENUM_VALUES_AS_NULL` to the + Jackson `ObjectMapper` configuration. * Added `SECUREPAY` to the `Payment.Processor` enum. * `WebServiceClient.Builder` now has an `httpClient()` method to allow passing in a custom `HttpClient`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..69c3d2e1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,445 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build and Test Commands + +### Running Tests +```bash +mvn test +``` + +### Running a Single Test Class +```bash +mvn test -Dtest=WebServiceClientTest +``` + +### Running a Single Test Method +```bash +mvn test -Dtest=WebServiceClientTest#testFullScoreTransaction +``` + +### Building the Project +```bash +mvn package +``` + +### Clean Build +```bash +mvn clean package +``` + +### Running Checkstyle +```bash +mvn checkstyle:check +``` + +Checkstyle runs automatically during the `test` phase and enforces Google Java Style conventions. + +### Generating Javadocs +```bash +mvn javadoc:javadoc +``` + +## Architecture Overview + +### Core Components + +**WebServiceClient** (`src/main/java/com/maxmind/minfraud/WebServiceClient.java`) +- Main entry point for the API +- Provides methods: `score()`, `insights()`, `factors()`, and `reportTransaction()` +- Uses Java's built-in `HttpClient` for HTTP communication +- **Thread-safe** and designed to be reused across multiple requests for connection pooling +- Handles authentication via Basic Auth headers +- Supports configurable timeouts, proxies, and custom `HttpClient` instances + +**Request Models** (`src/main/java/com/maxmind/minfraud/request/`) +- All request classes extend `AbstractModel` +- Built using the Builder pattern (e.g., `Transaction.Builder`, `Device.Builder`) +- `Transaction` is the primary request object composed of multiple optional sub-models: + - `Account`, `Billing`, `CreditCard`, `Device`, `Email`, `Event`, `Order`, `Payment`, `Shipping` + - `ShoppingCartItem` (can have multiple) + - `CustomInputs` (for custom key-value pairs) +- All request models serialize to JSON via `toJson()` method +- Immutable after construction + +**Response Models** (`src/main/java/com/maxmind/minfraud/response/`) +- Three main response types: `ScoreResponse`, `InsightsResponse`, `FactorsResponse` +- Use Java records (as of version 4.0.0) with deprecated getter methods for backwards compatibility +- Implement `JsonSerializable` interface +- `InsightsResponse` and `FactorsResponse` extend `ScoreResponse` with additional fields +- Response models include GeoIP2 data (this library depends on `com.maxmind.geoip2:geoip2`) + +**Exception Hierarchy** (`src/main/java/com/maxmind/minfraud/exception/`) +- `MinFraudException` (base checked exception) + - `AuthenticationException` + - `InsufficientFundsException` + - `InvalidRequestException` + - `PermissionRequiredException` +- `HttpException` (for unexpected HTTP errors) + +**JSON Handling** +- Uses Jackson for serialization/deserialization +- Centralized `Mapper` class provides configured `ObjectMapper` instance +- JSON property names use snake_case (e.g., `risk_score`, `ip_address`) +- `@JsonProperty` annotations map between camelCase Java and snake_case JSON + +### Key Design Patterns + +1. **Builder Pattern**: All request models use nested Builder classes for object construction +2. **Immutability**: Request models are immutable after construction; response models use records +3. **Composition**: The `Transaction` class composes multiple optional sub-models +4. **Thread Safety**: `WebServiceClient` is thread-safe and should be reused (enables connection pooling) +5. **Empty Object Defaults**: Response models return empty objects instead of null for better API ergonomics + +## Java Code Guidelines + +### Java Version +This project requires **Java 17+**. Use modern Java features appropriately: +- Records for immutable data models +- `var` for local variables when type is obvious +- Switch expressions +- Text blocks for multi-line strings +- Pattern matching where applicable + +### Code Style +The project uses **Google Java Style** with Checkstyle enforcement (see `checkstyle.xml`): +- 4 spaces for indentation (no tabs) +- 100 character line length limit +- Opening braces on same line +- Member names: camelCase starting with lowercase (minimum 2 characters) +- Parameters: camelCase (single letter allowed) +- Constants: UPPER_SNAKE_CASE +- No star imports +- Variables should be declared close to where they're used +- No abbreviations in names except standard ones (e.g., ID, IP, URI, URL, JSON) + +Run `mvn checkstyle:check` to verify compliance before committing. + +### Javadoc Requirements +- All public classes, methods, and constructors require Javadoc +- Public fields require Javadoc +- Use `@param`, `@return`, `@throws` tags appropriately +- Records should document parameters in the record declaration +- Include examples in Javadoc where helpful + +### Record Conventions + +#### Alphabetical Parameter Ordering +Record parameters are **always** ordered alphabetically by field name for consistency: + +```java +public record ScoreResponse( + Disposition disposition, // D + Double fundsRemaining, // F + UUID id, // I + ScoreIpAddress ipAddress, // I (after "id") + Integer queriesRemaining, // Q + Double riskScore, // R + List warnings // W +) implements JsonSerializable { + // ... +} +``` + +#### Compact Canonical Constructors +Use compact canonical constructors to set defaults and ensure non-null values: + +```java +public record ScoreResponse(...) { + public ScoreResponse { + disposition = disposition != null ? disposition : new Disposition(); + ipAddress = ipAddress != null ? ipAddress : new ScoreIpAddress(); + warnings = warnings != null ? List.copyOf(warnings) : List.of(); + } +} +``` + +This ensures users can safely call methods without null checks: `response.disposition().action()`. + +#### Enum Pattern for Response Models + +Response model enums use a simple, forward-compatible pattern that gracefully handles unknown values from the server. + +**Pattern:** +```java +public enum Status { + LIVE, + PARKED, + DNS_ERROR; + + @Override + public String toString() { + return name().toLowerCase(); + } +} +``` + +**How it works:** +- Enum constants use `UPPER_SNAKE_CASE` (e.g., `DNS_ERROR`, `ISP_EMAIL`) +- Override `toString()` to return `name().toLowerCase()` for JSON serialization +- The `Mapper` class configures Jackson with: + - `READ_ENUMS_USING_TO_STRING` - Deserializes using `toString()` + - `READ_UNKNOWN_ENUM_VALUES_AS_NULL` - Unknown values become `null` instead of throwing exceptions + +**Example:** +```java +// Serialization: DNS_ERROR → "dns_error" +Status status = Status.DNS_ERROR; +System.out.println(status); // "dns_error" + +// Deserialization: "dns_error" → DNS_ERROR +// Unknown: "future_value" → null (no exception!) +``` + +**When to use:** +- Use enums for response fields with fixed/enumerated values +- Use String for open-ended text fields +- Request enums use the same pattern but don't need forward compatibility concerns + +### Deprecation Strategy + +**Do NOT add deprecated getter methods for new fields.** Deprecated getters only exist for backward compatibility with fields that had JavaBeans-style getters before the record migration in version 4.0.0. + +When deprecating existing fields: + +```java +public record Response( + @Deprecated(since = "4.x.0", forRemoval = true) + @JsonProperty("old_field") + String oldField, + + @JsonProperty("new_field") + String newField +) { + // The record accessor oldField() is automatically marked as deprecated + + // Keep the old deprecated getter ONLY if it existed before v4.0.0 + @Deprecated(since = "4.x.0", forRemoval = true) + public String getOldField() { + return oldField(); + } +} +``` + +Include helpful deprecation messages in JavaDoc pointing to alternatives. + +### Avoiding Breaking Changes in Minor Versions + +When adding a new field to a record during a **minor version release** (e.g., 4.1.0 → 4.2.0), you must maintain backward compatibility for code that constructs records directly. + +**The Problem:** Adding a field to a record changes the canonical constructor signature, breaking existing code. + +**The Solution:** Add a deprecated constructor matching the old signature: + +```java +public record Email( + @JsonProperty("address") + String address, + + @JsonProperty("domain") + String domain, + + // NEW FIELD added in minor version 4.2.0 (inserted alphabetically between "domain" and "first_seen") + @JsonProperty("domain_last_seen") + LocalDate domainLastSeen, + + @JsonProperty("first_seen") + LocalDate firstSeen +) { + // Updated default constructor with new field + public Email() { + this(null, null, null, null); + } + + // Deprecated constructor maintaining old signature for backward compatibility + @Deprecated(since = "4.2.0", forRemoval = true) + public Email( + String address, + String domain, + LocalDate firstSeen + ) { + // Call new constructor with null for the new field (in alphabetical position) + this(address, domain, null, firstSeen); + } +} +``` + +**For Major Versions (e.g., 4.x → 5.0):** Skip the deprecated constructor—breaking changes are expected. + +Update `CHANGELOG.md` when adding fields: +```markdown +## 4.2.0 (2024-xx-xx) + +* A new `domainLastSeen` field has been added to the `Email` response object... +``` + +### Testing Conventions +- Tests use JUnit 5 (Jupiter) +- Tests use WireMock for HTTP mocking +- Test methods should have descriptive names starting with `test` +- Use static imports for assertions and matchers +- Full request/response examples in test resources: `src/test/resources/` +- Update test JSON fixtures when adding response fields +- Verify proper serialization/deserialization in tests + +### Multi-threaded Safety + +Both request and response classes are immutable and thread-safe. `WebServiceClient` is explicitly designed to be thread-safe and should be reused: + +```java +// Good: Create once, share across threads +WebServiceClient client = new WebServiceClient.Builder(accountId, licenseKey).build(); + +// Use in multiple threads +executor.submit(() -> client.score(transaction1)); +executor.submit(() -> client.score(transaction2)); +``` + +Reusing the client enables connection pooling and improves performance. + +## Working with This Codebase + +### Adding New Request Fields + +1. Add the field to the appropriate request class in `request/` package +2. Follow the Builder pattern used by other request classes: + ```java + public Builder fieldName(Type val) { + fieldName = val; + return this; + } + ``` +3. Add proper Javadoc and `@JsonProperty` annotation with snake_case name +4. Add validation in the builder if needed (e.g., throw `IllegalArgumentException`) +5. Update corresponding test class in `src/test/java/` +6. Add test JSON examples in `src/test/resources/` + +### Adding New Response Fields + +1. **Determine alphabetical position** for the new field +2. **Add to the record parameters** with `@JsonProperty`: + ```java + @JsonProperty("field_name") + Type fieldName, + ``` +3. **For minor version releases**: Add a deprecated constructor matching the old signature (see "Avoiding Breaking Changes") +4. **Handle null values** in the compact canonical constructor if needed +5. **Do NOT add a deprecated getter** for the new field +6. **Add JavaDoc** describing the field +7. **Update test fixtures** (`src/test/resources/`) with example data +8. **Add test assertions** to verify proper deserialization +9. **Update CHANGELOG.md** + +### Adding a New Response Record + +When creating an entirely new record class in `response/`: + +1. Use Java record syntax +2. Alphabetize parameters by field name +3. Add `@JsonProperty` annotations for all fields +4. Implement `JsonSerializable` interface +5. Add a compact canonical constructor to set defaults for null values +6. Provide comprehensive JavaDoc for all parameters +7. **Do NOT add deprecated getters** (only needed for legacy compatibility) + +### Debugging HTTP Issues +- WireMock runs on dynamic ports in tests (`@RegisterExtension` with `dynamicPort()`) +- Check `WebServiceClient` for HTTP request construction +- Response body must be fully consumed even on errors (see `exhaustBody()` method) +- Error responses (4xx) include `code` and `error` fields in JSON body +- Use `verifyRequestFor()` helper in tests to check sent JSON + +## Common Pitfalls and Solutions + +### Problem: Breaking Changes in Minor Versions +Adding a new field to a record changes the canonical constructor signature, breaking existing code. + +**Solution**: For minor version releases, add a deprecated constructor that maintains the old signature. See "Avoiding Breaking Changes in Minor Versions" section for details. + +### Problem: Record Constructor Ambiguity +When you have two constructors with similar signatures, you may get "ambiguous constructor" errors. + +**Solution**: Cast `null` parameters to their specific type: +```java +this((String) null, (LocalDate) null, (Integer) null); +``` + +### Problem: Test Failures After Adding New Fields +After adding new fields to a response model, tests fail with deserialization errors. + +**Solution**: Update **all** related test fixtures: +1. Test JSON files (e.g., `score-response.json`, `insights-response.json`) +2. In-line JSON in test classes +3. Test assertions in `*ResponseTest.java` files + +### Problem: Checkstyle Failures +Code style violations prevent merging. + +**Solution**: Run `mvn checkstyle:check` regularly during development. Common issues: +- Lines exceeding 100 characters +- Missing Javadoc on public methods +- Incorrect indentation (use 4 spaces) +- Star imports + +## Useful Patterns + +### Pattern: Compact Canonical Constructor +Use to set defaults and ensure non-null values: + +```java +public record InsightsResponse(...) { + public InsightsResponse { + // Ensure non-null with empty defaults + disposition = disposition != null ? disposition : new Disposition(); + ipAddress = ipAddress != null ? ipAddress : new IpAddress(); + warnings = warnings != null ? List.copyOf(warnings) : List.of(); + } +} +``` + +### Pattern: Empty Object Defaults +Return empty objects instead of null for better API ergonomics: + +```java +// Users can safely call without null checks +String action = response.disposition().action(); // Works even if disposition is "empty" +``` + +### Pattern: JsonSerializable Interface +All models implement `JsonSerializable` for consistent JSON output: + +```java +public interface JsonSerializable { + default String toJson() throws IOException { + return Mapper.get().writeValueAsString(this); + } +} +``` + +Usage: +```java +InsightsResponse response = client.insights(transaction); +String json = response.toJson(); // Pretty-printed JSON output +``` + +## Dependencies + +The project depends on: +- **Jackson** (core, databind, annotations, datatype-jsr310): JSON handling +- **GeoIP2 Java API**: Sibling library providing GeoIP2 models used in responses +- **JUnit 5** (Jupiter): Testing framework +- **WireMock**: HTTP mocking for tests +- **jsonassert**: JSON comparison in tests + +When updating dependencies, test thoroughly as noted in `README.dev.md`. + +## Release Process + +See `README.dev.md` for detailed release instructions. Key points: +- Releases require GPG signing +- Requires access to Central Portal (formerly Sonatype) +- Release script is `./dev-bin/release.sh` +- Update `CHANGELOG.md` before releasing with version and date +- Review open issues and PRs before releasing +- Bump copyright year in `README.md` if appropriate diff --git a/src/main/java/com/maxmind/minfraud/Mapper.java b/src/main/java/com/maxmind/minfraud/Mapper.java index c9fbfd63..d383e646 100644 --- a/src/main/java/com/maxmind/minfraud/Mapper.java +++ b/src/main/java/com/maxmind/minfraud/Mapper.java @@ -14,6 +14,8 @@ class Mapper { .addModule(new JavaTimeModule()) .defaultDateFormat(new StdDateFormat().withColonInTimeZone(true)) .enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING) + .enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL) .disable(MapperFeature.CAN_OVERRIDE_ACCESS_MODIFIERS) .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) diff --git a/src/main/java/com/maxmind/minfraud/response/EmailDomain.java b/src/main/java/com/maxmind/minfraud/response/EmailDomain.java index dc4452ae..267c7661 100644 --- a/src/main/java/com/maxmind/minfraud/response/EmailDomain.java +++ b/src/main/java/com/maxmind/minfraud/response/EmailDomain.java @@ -7,18 +7,78 @@ /** * This class contains minFraud response data related to the email domain. * - * @param firstSeen A date to identify the date an email domain was first seen by MaxMind. + * @param classification A classification of the email domain. Possible values are: business, + * education, government, isp_email. + * @param firstSeen The date an email domain was first seen by MaxMind. + * @param risk A risk score associated with the email domain, ranging from 0.01 to 99. + * Higher scores indicate higher risk. + * @param visit An {@code EmailDomainVisit} object containing information about an + * automated visit to the email domain. + * @param volume The activity on the email domain across the minFraud network, expressed in + * sightings per million. This value ranges from 0.001 to 1,000,000. */ public record EmailDomain( + @JsonProperty("classification") + Classification classification, + @JsonProperty("first_seen") - LocalDate firstSeen + LocalDate firstSeen, + + @JsonProperty("risk") + Double risk, + + @JsonProperty("visit") + EmailDomainVisit visit, + + @JsonProperty("volume") + Double volume ) implements JsonSerializable { + /** + * The classification of an email domain. + */ + public enum Classification { + /** + * A business email domain. + */ + BUSINESS, + + /** + * An educational institution email domain. + */ + EDUCATION, + + /** + * A government email domain. + */ + GOVERNMENT, + + /** + * An ISP-provided email domain (e.g., gmail.com, yahoo.com). + */ + ISP_EMAIL; + + /** + * @return a string representation of the classification in lowercase with underscores. + */ + @Override + public String toString() { + return name().toLowerCase(); + } + } + + /** + * Compact canonical constructor that sets defaults for null values. + */ + public EmailDomain { + visit = visit != null ? visit : new EmailDomainVisit(); + } + /** * Constructs an instance of {@code EmailDomain} with no data. */ public EmailDomain() { - this(null); + this(null, null, null, null, null); } /** diff --git a/src/main/java/com/maxmind/minfraud/response/EmailDomainVisit.java b/src/main/java/com/maxmind/minfraud/response/EmailDomainVisit.java new file mode 100644 index 00000000..c3e1428b --- /dev/null +++ b/src/main/java/com/maxmind/minfraud/response/EmailDomainVisit.java @@ -0,0 +1,76 @@ +package com.maxmind.minfraud.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.maxmind.minfraud.JsonSerializable; +import java.time.LocalDate; + +/** + * This class contains information about an automated visit to the email domain. + * + * @param hasRedirect Whether the domain redirects to another URL. This field is only present if + * the value is true. + * @param lastVisitedOn The date when the automated visit was last completed. + * @param status The status of the domain based on the automated visit. Possible values are: + * live, dns_error, network_error, http_error, parked, pre_development. + */ +public record EmailDomainVisit( + @JsonProperty("has_redirect") + Boolean hasRedirect, + + @JsonProperty("last_visited_on") + LocalDate lastVisitedOn, + + @JsonProperty("status") + Status status +) implements JsonSerializable { + + /** + * The status of an email domain based on an automated visit. + */ + public enum Status { + /** + * The domain is live and responding normally. + */ + LIVE, + + /** + * A DNS error occurred when attempting to visit the domain. + */ + DNS_ERROR, + + /** + * A network error occurred when attempting to visit the domain. + */ + NETWORK_ERROR, + + /** + * An HTTP error occurred when attempting to visit the domain. + */ + HTTP_ERROR, + + /** + * The domain is parked. + */ + PARKED, + + /** + * The domain is in pre-development. + */ + PRE_DEVELOPMENT; + + /** + * @return a string representation of the status in lowercase with underscores. + */ + @Override + public String toString() { + return name().toLowerCase(); + } + } + + /** + * Constructs an instance of {@code EmailDomainVisit} with no data. + */ + public EmailDomainVisit() { + this(null, null, null); + } +} diff --git a/src/test/java/com/maxmind/minfraud/WebServiceClientTest.java b/src/test/java/com/maxmind/minfraud/WebServiceClientTest.java index 920bc206..f2cc57e9 100644 --- a/src/test/java/com/maxmind/minfraud/WebServiceClientTest.java +++ b/src/test/java/com/maxmind/minfraud/WebServiceClientTest.java @@ -18,6 +18,7 @@ import static org.hamcrest.core.StringStartsWith.startsWith; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -28,6 +29,9 @@ import com.maxmind.minfraud.exception.InsufficientFundsException; import com.maxmind.minfraud.exception.InvalidRequestException; import com.maxmind.minfraud.exception.MinFraudException; +import com.maxmind.minfraud.response.EmailDomain; +import com.maxmind.minfraud.response.EmailDomainVisit; +import java.time.LocalDate; import com.maxmind.minfraud.exception.PermissionRequiredException; import com.maxmind.minfraud.request.Device; import com.maxmind.minfraud.request.Shipping; @@ -118,6 +122,15 @@ public void testFullInsightsTransaction() throws Exception { assertTrue(response.creditCard().isVirtual()); + // Test email domain fields + assertEquals(EmailDomain.Classification.EDUCATION, response.email().domain().classification()); + assertEquals(15.5, response.email().domain().risk()); + assertEquals(630000.0, response.email().domain().volume()); + assertNotNull(response.email().domain().visit()); + assertEquals(EmailDomainVisit.Status.LIVE, response.email().domain().visit().status()); + assertTrue(response.email().domain().visit().hasRedirect()); + assertEquals(LocalDate.parse("2024-11-15"), response.email().domain().visit().lastVisitedOn()); + var reasons = response.ipAddress().riskReasons(); assertEquals(2, reasons.size(), "two IP risk reasons"); @@ -153,6 +166,14 @@ public void testFullFactorsTransaction() throws Exception { "response.ipAddress().representedCountry().isInEuropeanUnion() does not return false" ); + // Test email domain fields + assertEquals(EmailDomain.Classification.ISP_EMAIL, response.email().domain().classification()); + assertEquals(25.0, response.email().domain().risk()); + assertEquals(500000.5, response.email().domain().volume()); + assertNotNull(response.email().domain().visit()); + assertEquals(EmailDomainVisit.Status.PARKED, response.email().domain().visit().status()); + assertFalse(response.email().domain().visit().hasRedirect()); + assertEquals(LocalDate.parse("2024-10-20"), response.email().domain().visit().lastVisitedOn()); assertEquals("152.216.7.110", response.ipAddress().traits().ipAddress().getHostAddress()); assertEquals("81.2.69.0/24", diff --git a/src/test/java/com/maxmind/minfraud/response/AbstractOutputTest.java b/src/test/java/com/maxmind/minfraud/response/AbstractOutputTest.java index 1b424aca..27c09345 100644 --- a/src/test/java/com/maxmind/minfraud/response/AbstractOutputTest.java +++ b/src/test/java/com/maxmind/minfraud/response/AbstractOutputTest.java @@ -20,6 +20,8 @@ T deserialize(Class cls, String json) throws IOException { .addModule(new JavaTimeModule()) .defaultDateFormat(new StdDateFormat().withColonInTimeZone(true)) .enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING) + .enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL) .disable(MapperFeature.CAN_OVERRIDE_ACCESS_MODIFIERS) .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) diff --git a/src/test/java/com/maxmind/minfraud/response/EmailDomainTest.java b/src/test/java/com/maxmind/minfraud/response/EmailDomainTest.java index e57651a1..25ea212c 100644 --- a/src/test/java/com/maxmind/minfraud/response/EmailDomainTest.java +++ b/src/test/java/com/maxmind/minfraud/response/EmailDomainTest.java @@ -1,8 +1,13 @@ package com.maxmind.minfraud.response; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.jr.ob.JSON; +import com.maxmind.minfraud.response.EmailDomain.Classification; +import com.maxmind.minfraud.response.EmailDomainVisit.Status; import java.time.LocalDate; import org.junit.jupiter.api.Test; @@ -22,4 +27,143 @@ public void testEmailDomain() throws Exception { assertEquals(LocalDate.parse("2014-02-03"), domain.firstSeen()); } + + @Test + public void testEmailDomainWithAllFields() throws Exception { + EmailDomain domain = this.deserialize( + EmailDomain.class, + JSON.std + .composeString() + .startObject() + .put("classification", "education") + .put("first_seen", "2019-01-15") + .put("risk", 15.5) + .startObjectField("visit") + .put("has_redirect", true) + .put("last_visited_on", "2024-11-15") + .put("status", "live") + .end() + .put("volume", 630000.0) + .end() + .finish() + ); + + assertEquals(Classification.EDUCATION, domain.classification()); + assertEquals("education", domain.classification().toString()); + assertEquals(LocalDate.parse("2019-01-15"), domain.firstSeen()); + assertEquals(15.5, domain.risk()); + assertEquals(630000.0, domain.volume()); + + assertNotNull(domain.visit()); + assertTrue(domain.visit().hasRedirect()); + assertEquals(LocalDate.parse("2024-11-15"), domain.visit().lastVisitedOn()); + assertEquals(Status.LIVE, domain.visit().status()); + } + + @Test + public void testEmailDomainWithBusinessClassification() throws Exception { + EmailDomain domain = this.deserialize( + EmailDomain.class, + JSON.std + .composeString() + .startObject() + .put("classification", "business") + .put("risk", 5.0) + .end() + .finish() + ); + + assertEquals(Classification.BUSINESS, domain.classification()); + assertEquals("business", domain.classification().toString()); + assertEquals(5.0, domain.risk()); + } + + @Test + public void testEmailDomainWithGovernmentClassification() throws Exception { + EmailDomain domain = this.deserialize( + EmailDomain.class, + JSON.std + .composeString() + .startObject() + .put("classification", "government") + .end() + .finish() + ); + + assertEquals(Classification.GOVERNMENT, domain.classification()); + assertEquals("government", domain.classification().toString()); + } + + @Test + public void testEmailDomainWithIspEmailClassification() throws Exception { + EmailDomain domain = this.deserialize( + EmailDomain.class, + JSON.std + .composeString() + .startObject() + .put("classification", "isp_email") + .put("volume", 500000.5) + .end() + .finish() + ); + + assertEquals(Classification.ISP_EMAIL, domain.classification()); + assertEquals("isp_email", domain.classification().toString()); + assertEquals(500000.5, domain.volume()); + } + + @Test + public void testEmailDomainWithUnknownClassification() throws Exception { + EmailDomain domain = this.deserialize( + EmailDomain.class, + JSON.std + .composeString() + .startObject() + .put("classification", "future_new_classification") + .put("risk", 20.0) + .end() + .finish() + ); + + assertNull(domain.classification()); + assertEquals(20.0, domain.risk()); + } + + @Test + public void testEmailDomainWithVisitOnly() throws Exception { + EmailDomain domain = this.deserialize( + EmailDomain.class, + JSON.std + .composeString() + .startObject() + .startObjectField("visit") + .put("status", "parked") + .put("last_visited_on", "2024-10-20") + .end() + .end() + .finish() + ); + + assertNotNull(domain.visit()); + assertEquals(Status.PARKED, domain.visit().status()); + assertEquals(LocalDate.parse("2024-10-20"), domain.visit().lastVisitedOn()); + } + + @Test + public void testEmailDomainEmpty() throws Exception { + EmailDomain domain = this.deserialize( + EmailDomain.class, + JSON.std + .composeString() + .startObject() + .end() + .finish() + ); + + assertNull(domain.classification()); + assertNull(domain.firstSeen()); + assertNull(domain.risk()); + assertNotNull(domain.visit()); + assertNull(domain.volume()); + } } diff --git a/src/test/java/com/maxmind/minfraud/response/EmailDomainVisitTest.java b/src/test/java/com/maxmind/minfraud/response/EmailDomainVisitTest.java new file mode 100644 index 00000000..d0af2b0f --- /dev/null +++ b/src/test/java/com/maxmind/minfraud/response/EmailDomainVisitTest.java @@ -0,0 +1,150 @@ +package com.maxmind.minfraud.response; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.jr.ob.JSON; +import com.maxmind.minfraud.response.EmailDomainVisit.Status; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; + +public class EmailDomainVisitTest extends AbstractOutputTest { + + @Test + public void testEmailDomainVisitWithAllFields() throws Exception { + EmailDomainVisit visit = this.deserialize( + EmailDomainVisit.class, + JSON.std + .composeString() + .startObject() + .put("has_redirect", true) + .put("last_visited_on", "2024-11-15") + .put("status", "live") + .end() + .finish() + ); + + assertTrue(visit.hasRedirect()); + assertEquals(LocalDate.parse("2024-11-15"), visit.lastVisitedOn()); + assertEquals(Status.LIVE, visit.status()); + assertEquals("live", visit.status().toString()); + } + + @Test + public void testEmailDomainVisitWithMinimalFields() throws Exception { + EmailDomainVisit visit = this.deserialize( + EmailDomainVisit.class, + JSON.std + .composeString() + .startObject() + .put("status", "parked") + .end() + .finish() + ); + + assertNull(visit.hasRedirect()); + assertNull(visit.lastVisitedOn()); + assertEquals(Status.PARKED, visit.status()); + assertEquals("parked", visit.status().toString()); + } + + @Test + public void testEmailDomainVisitWithDnsError() throws Exception { + EmailDomainVisit visit = this.deserialize( + EmailDomainVisit.class, + JSON.std + .composeString() + .startObject() + .put("status", "dns_error") + .put("last_visited_on", "2024-10-01") + .end() + .finish() + ); + + assertEquals(Status.DNS_ERROR, visit.status()); + assertEquals("dns_error", visit.status().toString()); + assertEquals(LocalDate.parse("2024-10-01"), visit.lastVisitedOn()); + } + + @Test + public void testEmailDomainVisitWithNetworkError() throws Exception { + EmailDomainVisit visit = this.deserialize( + EmailDomainVisit.class, + JSON.std + .composeString() + .startObject() + .put("status", "network_error") + .end() + .finish() + ); + + assertEquals(Status.NETWORK_ERROR, visit.status()); + assertEquals("network_error", visit.status().toString()); + } + + @Test + public void testEmailDomainVisitWithHttpError() throws Exception { + EmailDomainVisit visit = this.deserialize( + EmailDomainVisit.class, + JSON.std + .composeString() + .startObject() + .put("status", "http_error") + .end() + .finish() + ); + + assertEquals(Status.HTTP_ERROR, visit.status()); + assertEquals("http_error", visit.status().toString()); + } + + @Test + public void testEmailDomainVisitWithPreDevelopment() throws Exception { + EmailDomainVisit visit = this.deserialize( + EmailDomainVisit.class, + JSON.std + .composeString() + .startObject() + .put("status", "pre_development") + .end() + .finish() + ); + + assertEquals(Status.PRE_DEVELOPMENT, visit.status()); + assertEquals("pre_development", visit.status().toString()); + } + + @Test + public void testEmailDomainVisitWithUnknownStatus() throws Exception { + EmailDomainVisit visit = this.deserialize( + EmailDomainVisit.class, + JSON.std + .composeString() + .startObject() + .put("status", "future_new_status") + .put("last_visited_on", "2024-11-01") + .end() + .finish() + ); + + assertNull(visit.status()); + assertEquals(LocalDate.parse("2024-11-01"), visit.lastVisitedOn()); + } + + @Test + public void testEmailDomainVisitEmpty() throws Exception { + EmailDomainVisit visit = this.deserialize( + EmailDomainVisit.class, + JSON.std + .composeString() + .startObject() + .end() + .finish() + ); + + assertNull(visit.hasRedirect()); + assertNull(visit.lastVisitedOn()); + assertNull(visit.status()); + } +} diff --git a/src/test/resources/test-data/factors-response.json b/src/test/resources/test-data/factors-response.json index c0fa536a..b9832db7 100644 --- a/src/test/resources/test-data/factors-response.json +++ b/src/test/resources/test-data/factors-response.json @@ -159,7 +159,15 @@ }, "email": { "domain": { - "first_seen": "2014-02-23" + "classification": "isp_email", + "first_seen": "2014-02-23", + "risk": 25.0, + "visit": { + "has_redirect": false, + "last_visited_on": "2024-10-20", + "status": "parked" + }, + "volume": 500000.5 }, "first_seen": "2017-01-02", "is_disposable": true, diff --git a/src/test/resources/test-data/insights-response.json b/src/test/resources/test-data/insights-response.json index 14de174a..80528c05 100644 --- a/src/test/resources/test-data/insights-response.json +++ b/src/test/resources/test-data/insights-response.json @@ -166,7 +166,15 @@ }, "email": { "domain": { - "first_seen": "2014-02-23" + "classification": "education", + "first_seen": "2014-02-23", + "risk": 15.5, + "visit": { + "has_redirect": true, + "last_visited_on": "2024-11-15", + "status": "live" + }, + "volume": 630000.0 }, "first_seen": "2017-01-02", "is_disposable": true,