From 39350fd13d80a72e34628c100baa6c82c70d2ba6 Mon Sep 17 00:00:00 2001 From: Jeroen Benckhuijsen Date: Tue, 16 Apr 2024 14:22:48 +0200 Subject: [PATCH] Added timezone handling features --- README.md | 49 +++- pom.xml | 2 +- .../firestore/unit/FirestoreTester.java | 36 ++- .../group9/firestore/unit/FirestoreUnit.java | 210 ++++++++++++++++-- .../firestore/unit/FirestoreUnitTest.java | 44 +++- src/test/resources/json/options_zoneid.json | 7 + src/test/resources/json/timezoned.json | 7 + 7 files changed, 314 insertions(+), 41 deletions(-) create mode 100644 src/test/resources/json/options_zoneid.json create mode 100644 src/test/resources/json/timezoned.json diff --git a/README.md b/README.md index 019002e..e85dcf6 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,8 @@ _testcollection: Note that in this example: * Collections always need to be prefixed with an underscore "_". This is to let the library differentiate between the Map datatype and collections. -* Date/time values need to be defined in ISO 8601 format +* Date/time values need to be defined in ISO 8601 format. You can also specify timezone information in your test data, + for example "2024-03-22T12:13:14.123+02:00". * In case a document should be skipped for checking (both for existance and the fields), prefix it with an underscore "_". In this case the document "testcollection/testdoc3" is optional. Firestore allows you to skip definition of all intermediate documents in a path. @@ -121,6 +122,52 @@ The use one of the `assertFirestoreJson()` or `assertFirestoreYaml()` methods (d reference data) to validate the contents of your database. In case the data does not match, an `AssertionError` will be thrown in a regular JUnit style. +### Options ### + +(New feature since 0.3) + +You can now specify options to customize the testing behaviour. Each of the default methods now has a variant which +takes an `Options` object. This `Options`-object will affect the way the library works. Updating the options can be done +in a fluent way. As default, the Options object will use "UTC" as the default timezone. + +```java +class Tester { + + void test() { + assertFirestoreJson( + firestore, + FirestoreUnit.options() + .withZoneId("UTC"), + my_file + ); + } +} +``` + +To specify the data to take the currently specified timezone, you need to enter the expected value in the JSON (or YAML) +document *without* the timezone information (so without the `Z` or `+02:00`); for example: + +```json +{ + "_testcollection" : { + "timezoned": { + "testTimezoned": "2024-03-22T12:13:14.123" + } + } +} +``` + +#### Specify the timezone to use for testing #### + +Use the `Options.withZoneId()` method to specify the `ZoneId` to use when validating timestamps. In case your application +stores dates in the database with a specific local timezone (i.e. not as UTC or the system default), then comparing dates +will fail as this will use the `ZoneId.systemDefault()`. You can override the zone id to use using this option. + +This setting is convenient when your application code automatically interprets dates using a local timezone. In case that +timezone also features daylight-saving time (DST), the actual timezone offset may differ depending on when the test is +executed. By specifying a timezone for comparison, dates can be specified as if timezones are ignored (e.g. as +`"2024-03-22T12:13:14.123Z"`). The tester will then automatically assume the same timezone as specified in the options. + ### Limitations ### This library has the following limitations: diff --git a/pom.xml b/pom.xml index 07b184a..70b7673 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ nl.group9 firestore-unit - 0.0.2 + 0.0.3 jar firestore-unit diff --git a/src/main/java/nl/group9/firestore/unit/FirestoreTester.java b/src/main/java/nl/group9/firestore/unit/FirestoreTester.java index 3de74f2..a5fe9e2 100644 --- a/src/main/java/nl/group9/firestore/unit/FirestoreTester.java +++ b/src/main/java/nl/group9/firestore/unit/FirestoreTester.java @@ -13,8 +13,9 @@ import org.opentest4j.AssertionFailedError; import java.time.LocalDateTime; -import java.time.ZoneId; +import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAccessor; import java.util.*; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; @@ -32,9 +33,11 @@ class FirestoreTester { private final JsonNode tree; private final Executor executor; private final DateTimeFormatter formatter; + private final FirestoreUnit.Options options; - public FirestoreTester(Firestore firestore, JsonNode tree) { + public FirestoreTester(Firestore firestore, FirestoreUnit.Options options, JsonNode tree) { this.firestore = firestore; + this.options = options; this.tree = tree; this.executor = MoreExecutors.directExecutor(); this.formatter = DateTimeFormatter.ISO_DATE_TIME; @@ -135,7 +138,11 @@ private void validateField(JsonNode value, Object docValue, String fieldPath) { } else if (value.isTextual()) { if (docValue instanceof Timestamp) { // Date and time - assertPrimitiveValue(value, docValue, Timestamp.class, this::jsonDateTimeToTimestamp, fieldPath); + assertPrimitiveValue(value, + timestampZoZonedDateTime((Timestamp) docValue), + ZonedDateTime.class, + this::jsonDateTimeToZonedDateTime, + fieldPath); } else if (docValue instanceof DocumentReference) { assertDocumentReference(value, docValue, fieldPath); } else { @@ -186,12 +193,23 @@ private void assertMapValue(JsonNode value, Object docValue, String fieldPath) { validateFields(value, fieldPath, mapDocValue::containsKey, mapDocValue::get); } - private Timestamp jsonDateTimeToTimestamp(JsonNode value) { - Date date = Date.from(LocalDateTime - .parse(value.asText(), this.formatter) - .atZone(ZoneId.systemDefault()) - .toInstant()); - return Timestamp.of(date); + private ZonedDateTime jsonDateTimeToZonedDateTime(JsonNode value) { + TemporalAccessor parseResult = formatter.parseBest(value.asText(), ZonedDateTime::from, LocalDateTime::from); + ZonedDateTime dateTime; + + if (parseResult instanceof LocalDateTime) { + dateTime = ((LocalDateTime) parseResult).atZone(options.getZoneId()); + } else { + dateTime = (ZonedDateTime) parseResult; + dateTime = dateTime.toInstant().atZone(options.getZoneId()); + } + + return dateTime; + } + + private ZonedDateTime timestampZoZonedDateTime(Timestamp timestamp) { + Date date = timestamp.toDate(); + return date.toInstant().atZone(options.getZoneId()); } private void assertType(Object docValue, Class type, String fieldPath) { diff --git a/src/main/java/nl/group9/firestore/unit/FirestoreUnit.java b/src/main/java/nl/group9/firestore/unit/FirestoreUnit.java index 1f14b2c..6009cdc 100644 --- a/src/main/java/nl/group9/firestore/unit/FirestoreUnit.java +++ b/src/main/java/nl/group9/firestore/unit/FirestoreUnit.java @@ -11,6 +11,7 @@ import java.io.InputStream; import java.io.Reader; import java.net.URL; +import java.time.ZoneId; import static org.junit.jupiter.api.Assertions.fail; @@ -26,12 +27,24 @@ public class FirestoreUnit { private FirestoreUnit() {} /** - * Validate the contents of the Firestore database is equal to the contents of the JSON provided. + * Validate the contents of the Firestore database is equal to the contents of the JSON provided. The default + * options are used. + * @see #assertFirestoreJson(Firestore, Options, String) * @param firestore The firestore instance to read from * @param json The JSON reference data as string */ public static void assertFirestoreJson(Firestore firestore, String json) { - assertFirestore(firestore, new ObjectMapper(), json); + assertFirestoreJson(firestore, options(), json); + } + + /** + * Validate the contents of the Firestore database is equal to the contents of the JSON provided. + * @param firestore The firestore instance to read from + * @param options The options for validation + * @param json The JSON reference data as String + */ + public static void assertFirestoreJson(Firestore firestore, Options options, String json) { + assertFirestore(firestore, new ObjectMapper(), options, json); } /** @@ -41,7 +54,18 @@ public static void assertFirestoreJson(Firestore firestore, String json) { * @param json The JSON reference data as file */ public static void assertFirestoreJson(Firestore firestore, File json) { - assertFirestore(firestore, new ObjectMapper(), json); + assertFirestoreJson(firestore, options(), json); + } + + /** + * Validate using a JSON File + * @see #assertFirestoreJson(Firestore, Options, String) + * @param firestore The firestore instance to read from + * @param options The options for validation + * @param json The JSON reference data as File + */ + public static void assertFirestoreJson(Firestore firestore, Options options, File json) { + assertFirestore(firestore, new ObjectMapper(), options, json); } /** @@ -51,7 +75,18 @@ public static void assertFirestoreJson(Firestore firestore, File json) { * @param json The JSON reference data as URL */ public static void assertFirestoreJson(Firestore firestore, URL json) { - assertFirestore(firestore, new ObjectMapper(), json); + assertFirestoreJson(firestore, options(), json); + } + + /** + * Validate using a JSON URL + * @see #assertFirestoreJson(Firestore, Options, String) + * @param firestore The firestore instance to read from + * @param options The options for validation + * @param json The JSON reference data as URL + */ + public static void assertFirestoreJson(Firestore firestore, Options options, URL json) { + assertFirestore(firestore, new ObjectMapper(), options, json); } /** @@ -61,7 +96,18 @@ public static void assertFirestoreJson(Firestore firestore, URL json) { * @param json The JSON reference data as Reader */ public static void assertFirestoreJson(Firestore firestore, Reader json) { - assertFirestore(firestore, new ObjectMapper(), json); + assertFirestoreJson(firestore, options(), json); + } + + /** + * Validate using a JSON Reader + * @see #assertFirestoreJson(Firestore, Options, String) + * @param firestore The firestore instance to read from + * @param options The options for validation + * @param json The JSON reference data as Reader + */ + public static void assertFirestoreJson(Firestore firestore, Options options, Reader json) { + assertFirestore(firestore, new ObjectMapper(), options, json); } /** @@ -71,7 +117,18 @@ public static void assertFirestoreJson(Firestore firestore, Reader json) { * @param json The JSON reference data as InputStream */ public static void assertFirestoreJson(Firestore firestore, InputStream json) { - assertFirestore(firestore, new ObjectMapper(), json); + assertFirestoreJson(firestore, options(), json); + } + + /** + * Validate using a JSON InputStream + * @see #assertFirestoreJson(Firestore, Options, String) + * @param firestore The firestore instance to read from + * @param options The options for validation + * @param json The JSON reference data as InputStream + */ + public static void assertFirestoreJson(Firestore firestore, Options options, InputStream json) { + assertFirestore(firestore, new ObjectMapper(), options, json); } /** @@ -81,7 +138,18 @@ public static void assertFirestoreJson(Firestore firestore, InputStream json) { * @param yaml The YAML reference data as String */ public static void assertFirestoreYaml(Firestore firestore, String yaml) { - assertFirestore(firestore, new YAMLMapper(), yaml); + assertFirestoreYaml(firestore, options(), yaml); + } + + /** + * Validate using a YAML String + * @see #assertFirestoreJson(Firestore, Options, String) + * @param firestore The firestore instance to read from + * @param options The options for validation + * @param yaml The YAML reference data as String + */ + public static void assertFirestoreYaml(Firestore firestore, Options options, String yaml) { + assertFirestore(firestore, new YAMLMapper(), options, yaml); } /** @@ -91,7 +159,18 @@ public static void assertFirestoreYaml(Firestore firestore, String yaml) { * @param yaml The YAML reference data as file */ public static void assertFirestoreYaml(Firestore firestore, File yaml) { - assertFirestore(firestore, new YAMLMapper(), yaml); + assertFirestoreYaml(firestore, options(), yaml); + } + + /** + * Validate using a YAML URL + * @see #assertFirestoreJson(Firestore, Options, String) + * @param firestore The firestore instance to read from + * @param options The options for validation + * @param yaml The YAML reference data as File + */ + public static void assertFirestoreYaml(Firestore firestore, Options options, File yaml) { + assertFirestore(firestore, new YAMLMapper(), options, yaml); } /** @@ -101,7 +180,18 @@ public static void assertFirestoreYaml(Firestore firestore, File yaml) { * @param yaml The YAML reference data as URL */ public static void assertFirestoreYaml(Firestore firestore, URL yaml) { - assertFirestore(firestore, new YAMLMapper(), yaml); + assertFirestoreYaml(firestore, options(), yaml); + } + + /** + * Validate using a YAML Reader + * @see #assertFirestoreJson(Firestore, Options, String) + * @param firestore The firestore instance to read from + * @param options The options for validation + * @param yaml The YAML reference data as URL + */ + public static void assertFirestoreYaml(Firestore firestore, Options options, URL yaml) { + assertFirestore(firestore, new YAMLMapper(), options, yaml); } /** @@ -111,7 +201,18 @@ public static void assertFirestoreYaml(Firestore firestore, URL yaml) { * @param yaml The YAML reference data as Reader */ public static void assertFirestoreYaml(Firestore firestore, Reader yaml) { - assertFirestore(firestore, new YAMLMapper(), yaml); + assertFirestoreYaml(firestore, options(), yaml); + } + + /** + * Validate using a YAML Reader + * @see #assertFirestoreJson(Firestore, Options, String) + * @param firestore The firestore instance to read from + * @param options The options for validation + * @param yaml The YAML reference data as Reader + */ + public static void assertFirestoreYaml(Firestore firestore, Options options, Reader yaml) { + assertFirestore(firestore, new YAMLMapper(), options, yaml); } /** @@ -121,51 +222,114 @@ public static void assertFirestoreYaml(Firestore firestore, Reader yaml) { * @param yaml The YAML reference data as InputStream */ public static void assertFirestoreYaml(Firestore firestore, InputStream yaml) { - assertFirestore(firestore, new YAMLMapper(), yaml); + assertFirestoreYaml(firestore, options(), yaml); + } + + /** + * Validate using a YAML InputStream + * @see #assertFirestoreJson(Firestore, Options, String) + * @param firestore The firestore instance to read from + * @param options The options for validation + * @param yaml The YAML reference data as InputStream + */ + public static void assertFirestoreYaml(Firestore firestore, Options options, InputStream yaml) { + assertFirestore(firestore, new YAMLMapper(), options, yaml); + } + + /** + * Return the default options + * @return the options + */ + public static Options options() { + return new Options(); + } + + /** + * Options to configure the behaviour of the testing setup. + */ + public static class Options { + private final ZoneId zoneId; + + /** + * Default constructor, sets default values for options + */ + private Options() { + zoneId = ZoneId.of("UTC"); + } + + /** + * Updating constructor. Used in the with*() methods. + * @param zoneId The zone id. + */ + private Options(ZoneId zoneId) { + this.zoneId = zoneId; + } + + /** + * Configure the zoneId to use when comparing timestamps + * @param zoneId The name of the zone Id + * @return The new options + */ + public Options withZoneId(String zoneId) { + return withZoneId(ZoneId.of(zoneId)); + } + + /** + * Configure the zoneId to use when comparing timestamps + * @param zoneId The zone Id + * @return The new options + */ + public Options withZoneId(ZoneId zoneId) { + return new Options(zoneId); + } + + ZoneId getZoneId() { + return zoneId; + } } - private static void assertFirestore(Firestore firestore, ObjectMapper mapper, String contents) { + private static void assertFirestore(Firestore firestore, ObjectMapper mapper, Options options, String contents) { try { - FirestoreUnit.assertFirestore(firestore, mapper.readTree(contents)); + FirestoreUnit.assertFirestore(firestore, options, mapper.readTree(contents)); } catch (JsonProcessingException e) { fail(e); } } - private static void assertFirestore(Firestore firestore, ObjectMapper mapper, File contents) { + private static void assertFirestore(Firestore firestore, ObjectMapper mapper, Options options, File contents) { try { - FirestoreUnit.assertFirestore(firestore, mapper.readTree(contents)); + FirestoreUnit.assertFirestore(firestore, options, mapper.readTree(contents)); } catch (IOException e) { fail(e); } } - private static void assertFirestore(Firestore firestore, ObjectMapper mapper, URL contents) { + private static void assertFirestore(Firestore firestore, ObjectMapper mapper, Options options, URL contents) { try { - FirestoreUnit.assertFirestore(firestore, mapper.readTree(contents)); + FirestoreUnit.assertFirestore(firestore, options, mapper.readTree(contents)); } catch (IOException e) { fail(e); } } - private static void assertFirestore(Firestore firestore, ObjectMapper mapper, Reader contents) { + private static void assertFirestore(Firestore firestore, ObjectMapper mapper, Options options, Reader contents) { try { - FirestoreUnit.assertFirestore(firestore, mapper.readTree(contents)); + FirestoreUnit.assertFirestore(firestore, options, mapper.readTree(contents)); } catch (IOException e) { fail(e); } } - private static void assertFirestore(Firestore firestore, ObjectMapper mapper, InputStream contents) { + private static void assertFirestore(Firestore firestore, ObjectMapper mapper, Options options, InputStream contents) { try { - FirestoreUnit.assertFirestore(firestore, mapper.readTree(contents)); + FirestoreUnit.assertFirestore(firestore, options, mapper.readTree(contents)); } catch (IOException e) { fail(e); } } - private static void assertFirestore(Firestore firestore, JsonNode tree) { - FirestoreTester tester = new FirestoreTester(firestore, tree); + private static void assertFirestore(Firestore firestore, Options options, JsonNode tree) { + FirestoreTester tester = new FirestoreTester(firestore, options, tree); tester.validate(); } diff --git a/src/test/java/nl/group9/firestore/unit/FirestoreUnitTest.java b/src/test/java/nl/group9/firestore/unit/FirestoreUnitTest.java index 2ce1b03..f5c5c7c 100644 --- a/src/test/java/nl/group9/firestore/unit/FirestoreUnitTest.java +++ b/src/test/java/nl/group9/firestore/unit/FirestoreUnitTest.java @@ -18,6 +18,7 @@ import java.net.URISyntaxException; import java.net.URL; import java.nio.file.Paths; +import java.time.*; import java.util.*; import static nl.group9.firestore.unit.FirestoreUnit.assertFirestoreJson; @@ -30,6 +31,9 @@ public class FirestoreUnitTest { private static final String CORRECT_JSON = "json/correct.json"; private static final String CORRECT_YAML = "yaml/correct.yaml"; + private static final ZoneId TIMEZONE = ZoneId.of("UTC+2"); + + @Container public static FirestoreEmulatorContainer emulator = new FirestoreEmulatorContainer( DockerImageName.parse("gcr.io/google.com/cloudsdktool/google-cloud-cli:441.0.0-emulators") @@ -50,11 +54,7 @@ public static void fillFirestore() throws Exception { "field1", 10, "field2", "text" )); - // "2024-03-22T12:13:14.123Z" - Calendar testDateTime = Calendar.getInstance(); - testDateTime.set(2024, Calendar.MARCH, 22, 12, 13, 14); - testDateTime.set(Calendar.MILLISECOND, 123); - testdoc1Fields.put("testDateTime", Timestamp.of(testDateTime.getTime())); + testdoc1Fields.put("testDateTime", timestampValue(ZoneId.of("UTC"))); testdoc1Fields.put("testReference", testcollection.document("ref1")); testdoc1Fields.put("testText", "Hello world"); @@ -74,9 +74,23 @@ public static void fillFirestore() throws Exception { Map testdoc4Fields = new HashMap<>(); testdoc4Fields.put("testText", "Hello Firestore"); testdoc4.set(testdoc4Fields).get(); + + // Timezone date validations + DocumentReference timezonedoc = testcollection.document("timezoned"); + Map timezonedocfields = new HashMap<>(); + timezonedocfields.put("testTimezoned", timestampValue(TIMEZONE)); + timezonedoc.set(timezonedocfields).get(); } } + private static Timestamp timestampValue(ZoneId timezone) { + // "2024-03-22T12:13:14.123Z" + LocalDateTime timezonedDate = LocalDateTime.of(2024, Month.MARCH, 22, 12, 13,14, 123000000); + ZonedDateTime zonedDateTime = timezonedDate.atZone(timezone); + Date zonedDate = Date.from(zonedDateTime.toInstant()); + return Timestamp.of(zonedDate); + } + @Test void testJsonString() throws Exception { try (Firestore firestore = connection()) { @@ -148,12 +162,28 @@ void testYamlInputStream() throws Exception { } @Test - void emptySubDocument() throws Exception { + void testEmptySubDocument() throws Exception { try (Firestore firestore = connection()) { assertFirestoreJson(firestore, asInputStream("json/missing_subdoc.json")); } } + @Test + void testTimezonedValue() throws Exception { + try (Firestore firestore = connection()) { + assertFirestoreJson(firestore, asInputStream("json/timezoned.json")); + } + } + + @Test + void testOptionZoneId() throws Exception { + try (Firestore firestore = connection()) { + assertFirestoreJson(firestore, + FirestoreUnit.options().withZoneId("UTC+2"), + asInputStream("json/options_zoneid.json")); + } + } + @Test void testArrayDifferentElements() { testInvalidFile("json/array_diff_element.json", "Field does not have the expected value at testcollection/testdoc1/testArray[0] ==> expected: but was: "); @@ -171,7 +201,7 @@ void testIncorrectBoolean() { @Test void testIncorrectDateTime() { - testInvalidFile("json/incorrect_datetime.json", "Field does not have the expected value at testcollection/testdoc1/testDateTime ==> expected: <1924-03-22T12:13:14.123000000Z> but was: <2024-03-22T11:13:14.123000000Z>"); + testInvalidFile("json/incorrect_datetime.json", "Field does not have the expected value at testcollection/testdoc1/testDateTime ==> expected: <1924-03-22T12:13:14.123Z[UTC]> but was: <2024-03-22T12:13:14.123Z[UTC]>"); } @Test diff --git a/src/test/resources/json/options_zoneid.json b/src/test/resources/json/options_zoneid.json new file mode 100644 index 0000000..f375221 --- /dev/null +++ b/src/test/resources/json/options_zoneid.json @@ -0,0 +1,7 @@ +{ + "_testcollection" : { + "timezoned": { + "testTimezoned": "2024-03-22T12:13:14.123" + } + } +} diff --git a/src/test/resources/json/timezoned.json b/src/test/resources/json/timezoned.json new file mode 100644 index 0000000..ab8c8e8 --- /dev/null +++ b/src/test/resources/json/timezoned.json @@ -0,0 +1,7 @@ +{ + "_testcollection" : { + "timezoned": { + "testTimezoned": "2024-03-22T12:13:14.123+02:00" + } + } +}