diff --git a/docs/data_types.md b/docs/data_types.md index 39cd8fd..951b631 100644 --- a/docs/data_types.md +++ b/docs/data_types.md @@ -7,7 +7,7 @@ This document provides information about how Tonik is mapping data types in Open | OAS Type | OAS Format | Dart Type | Dart Package | Comment | |----------|------------|-----------|--------------|---------| -| `string` | `date-time` | `DateTime` | `dart:core` | ISO 8601 datetime format | +| `string` | `date-time` | `DateTime` | `dart:core` | See [Timezone-Aware DateTime Parsing](#timezone-aware-datetime-parsing) | | `string` | `date` | `Date` | `tonik_util` | RFC3339 date format (YYYY-MM-DD) | | `string` | `decimal`, `currency`, `money`, `number` | `BigDecimal` | `big_decimal` | High-precision decimal numbers | | `string` | `enum` | `enum` | Generated | Custom enum type | @@ -19,3 +19,28 @@ This document provides information about how Tonik is mapping data types in Open | `boolean` | (any) | `bool` | `dart:core` | Boolean type | | `array` | (any) | `List` | `dart:core` | List of specified type | +### Timezone-Aware DateTime Parsing + +Tonik provides intelligent timezone-aware parsing for `date-time` format strings. The parsing behavior depends on the timezone information present in the input: + +> **⚠️ Important:** Before using timezone-aware parsing features, you must initialize the timezone database by calling `tz.initializeTimeZones()` from the `timezone` package. This is typically done in your application's setup code. + +All generated code will always expose Dart `DateTime` objects. However, standard Dart `DateTime` objects do not preserve timezone information, which is why Tonik uses `TZDateTime` internally during parsing to maintain timezone location data. During parsing, Tonik selects the most appropriate type to represent the date and time value: + +| Input Format | Return Type | Example | Description | +|--------------|-------------|---------|-------------| +| UTC (with Z) | `DateTime` (UTC) | `2023-12-25T15:30:45Z` | Standard Dart DateTime in UTC | +| Local (no timezone) | `DateTime` (local) | `2023-12-25T15:30:45` | Standard Dart DateTime in local timezone | +| Timezone offset | `TZDateTime` | `2023-12-25T15:30:45+05:00` | Timezone-aware DateTime with proper location | + + + +#### Timezone Location Selection + +For strings with timezone offsets (e.g., `+05:00`), Tonik intelligently selects the best matching timezone location: + +1. **Prefers common locations** from the timezone package's curated list of 535+ well-known timezones +2. **Accounts for DST changes** by checking the offset at the specific timestamp +3. **Avoids deprecated locations** (e.g., `US/Eastern` → `America/New_York`) +4. **Falls back to fixed offset** locations (`Etc/GMT±N`) when no match is found + diff --git a/packages/tonik_core/test/model/query_parameter_test.dart b/packages/tonik_core/test/model/query_parameter_test.dart index 732ede2..76446c1 100644 --- a/packages/tonik_core/test/model/query_parameter_test.dart +++ b/packages/tonik_core/test/model/query_parameter_test.dart @@ -23,17 +23,17 @@ void main() { final resolved = param.resolve(name: 'newName'); - expect(resolved.name, equals('newName')); - expect(resolved.rawName, equals('originalRawName')); - expect(resolved.description, equals('description')); + expect(resolved.name, 'newName'); + expect(resolved.rawName, 'originalRawName'); + expect(resolved.description, 'description'); expect(resolved.isRequired, isTrue); expect(resolved.isDeprecated, isFalse); expect(resolved.allowEmptyValue, isFalse); expect(resolved.allowReserved, isFalse); expect(resolved.explode, isFalse); - expect(resolved.model, equals(model)); - expect(resolved.encoding, equals(QueryParameterEncoding.form)); - expect(resolved.context, equals(context)); + expect(resolved.model, model); + expect(resolved.encoding, QueryParameterEncoding.form); + expect(resolved.context, context); }); test('resolve preserves original name when no new name provided', () { @@ -56,7 +56,7 @@ void main() { final resolved = param.resolve(); - expect(resolved.name, equals('originalName')); + expect(resolved.name, 'originalName'); }); test('QueryParameterAlias.resolve resolves with alias name', () { @@ -85,8 +85,8 @@ void main() { final resolved = alias.resolve(); - expect(resolved.name, equals('aliasName')); - expect(resolved.rawName, equals('originalRawName')); + expect(resolved.name, 'aliasName'); + expect(resolved.rawName, 'originalRawName'); }); test( @@ -117,7 +117,7 @@ void main() { final resolved = alias.resolve(name: 'overrideName'); - expect(resolved.name, equals('overrideName')); + expect(resolved.name, 'overrideName'); }, ); @@ -153,8 +153,8 @@ void main() { final resolved = secondAlias.resolve(); - expect(resolved.name, equals('secondAliasName')); - expect(resolved.rawName, equals('originalRawName')); + expect(resolved.name, 'secondAliasName'); + expect(resolved.rawName, 'originalRawName'); }); }); } diff --git a/packages/tonik_core/test/model/request_header_test.dart b/packages/tonik_core/test/model/request_header_test.dart index b6edca2..38b5147 100644 --- a/packages/tonik_core/test/model/request_header_test.dart +++ b/packages/tonik_core/test/model/request_header_test.dart @@ -22,16 +22,16 @@ void main() { final resolved = header.resolve(name: 'newName'); - expect(resolved.name, equals('newName')); - expect(resolved.rawName, equals('originalRawName')); - expect(resolved.description, equals('description')); + expect(resolved.name, 'newName'); + expect(resolved.rawName, 'originalRawName'); + expect(resolved.description, 'description'); expect(resolved.isRequired, isTrue); expect(resolved.isDeprecated, isFalse); expect(resolved.allowEmptyValue, isFalse); expect(resolved.explode, isFalse); - expect(resolved.model, equals(model)); - expect(resolved.encoding, equals(HeaderParameterEncoding.simple)); - expect(resolved.context, equals(context)); + expect(resolved.model, model); + expect(resolved.encoding, HeaderParameterEncoding.simple); + expect(resolved.context, context); }); test('resolve preserves original name when no new name provided', () { @@ -53,7 +53,7 @@ void main() { final resolved = header.resolve(); - expect(resolved.name, equals('originalName')); + expect(resolved.name, 'originalName'); }); test('RequestHeaderAlias.resolve resolves with alias name', () { @@ -81,8 +81,8 @@ void main() { final resolved = alias.resolve(); - expect(resolved.name, equals('aliasName')); - expect(resolved.rawName, equals('originalRawName')); + expect(resolved.name, 'aliasName'); + expect(resolved.rawName, 'originalRawName'); }); test( @@ -112,7 +112,7 @@ void main() { final resolved = alias.resolve(name: 'overrideName'); - expect(resolved.name, equals('overrideName')); + expect(resolved.name, 'overrideName'); }, ); @@ -147,8 +147,8 @@ void main() { final resolved = secondAlias.resolve(); - expect(resolved.name, equals('secondAliasName')); - expect(resolved.rawName, equals('originalRawName')); + expect(resolved.name, 'secondAliasName'); + expect(resolved.rawName, 'originalRawName'); }); }); } diff --git a/packages/tonik_core/test/model/response_header_test.dart b/packages/tonik_core/test/model/response_header_test.dart index 628766b..7bef64a 100644 --- a/packages/tonik_core/test/model/response_header_test.dart +++ b/packages/tonik_core/test/model/response_header_test.dart @@ -20,14 +20,14 @@ void main() { final resolved = header.resolve(name: 'newName'); - expect(resolved.name, equals('newName')); - expect(resolved.description, equals('description')); + expect(resolved.name, 'newName'); + expect(resolved.description, 'description'); expect(resolved.isRequired, isTrue); expect(resolved.isDeprecated, isFalse); expect(resolved.explode, isFalse); - expect(resolved.model, equals(model)); - expect(resolved.encoding, equals(ResponseHeaderEncoding.simple)); - expect(resolved.context, equals(context)); + expect(resolved.model, model); + expect(resolved.encoding, ResponseHeaderEncoding.simple); + expect(resolved.context, context); }); test('resolve preserves original name when no new name provided', () { @@ -47,7 +47,7 @@ void main() { final resolved = header.resolve(); - expect(resolved.name, equals('originalName')); + expect(resolved.name, 'originalName'); }); test('ResponseHeaderAlias.resolve resolves with alias name', () { @@ -73,8 +73,8 @@ void main() { final resolved = alias.resolve(); - expect(resolved.name, equals('aliasName')); - expect(resolved.description, equals('description')); + expect(resolved.name, 'aliasName'); + expect(resolved.description, 'description'); }); test( @@ -102,7 +102,7 @@ void main() { final resolved = alias.resolve(name: 'overrideName'); - expect(resolved.name, equals('overrideName')); + expect(resolved.name, 'overrideName'); }, ); @@ -135,8 +135,8 @@ void main() { final resolved = secondAlias.resolve(); - expect(resolved.name, equals('secondAliasName')); - expect(resolved.description, equals('description')); + expect(resolved.name, 'secondAliasName'); + expect(resolved.description, 'description'); }); }); } diff --git a/packages/tonik_generate/lib/src/util/to_json_value_expression_generator.dart b/packages/tonik_generate/lib/src/util/to_json_value_expression_generator.dart index 7d0a55d..80011cc 100644 --- a/packages/tonik_generate/lib/src/util/to_json_value_expression_generator.dart +++ b/packages/tonik_generate/lib/src/util/to_json_value_expression_generator.dart @@ -47,7 +47,7 @@ String? _getSerializationSuffix(Model model, bool isNullable) { isNullable || (model is EnumModel && model.isNullable) ? '?' : ''; return switch (model) { - DateTimeModel() => '$nullablePart.toIso8601String()', + DateTimeModel() => '$nullablePart.toTimeZonedIso8601String()', DecimalModel() => '$nullablePart.toString()', DateModel() || EnumModel() || diff --git a/packages/tonik_generate/test/src/model/class_json_generator_test.dart b/packages/tonik_generate/test/src/model/class_json_generator_test.dart index 7fb5505..8aba69c 100644 --- a/packages/tonik_generate/test/src/model/class_json_generator_test.dart +++ b/packages/tonik_generate/test/src/model/class_json_generator_test.dart @@ -258,7 +258,7 @@ void main() { const expectedMethod = ''' Object? toJson() => { r'name': name, - r'createdAt': createdAt.toIso8601String(), + r'createdAt': createdAt.toTimeZonedIso8601String(), r'status': status.toJson(), r'homeAddress': homeAddress?.toJson(), }; @@ -323,7 +323,9 @@ void main() { const expectedMethod = ''' Object? toJson() => { r'tags': tags, - r'meetingTimes': meetingTimes.map((e) => e.toIso8601String()).toList(), + r'meetingTimes': meetingTimes + .map((e) => e.toTimeZonedIso8601String()) + .toList(), r'addresses': addresses?.map((e) => e.toJson()).toList(), }; '''; diff --git a/packages/tonik_generate/test/src/operation/options_generator_test.dart b/packages/tonik_generate/test/src/operation/options_generator_test.dart index dd7b4cf..255633c 100644 --- a/packages/tonik_generate/test/src/operation/options_generator_test.dart +++ b/packages/tonik_generate/test/src/operation/options_generator_test.dart @@ -367,7 +367,7 @@ void main() { allowEmpty: false, ); headers[r'X-Required-Date'] = headerEncoder.encode( - xRequiredDate.toIso8601String(), + xRequiredDate.toTimeZonedIso8601String(), explode: false, allowEmpty: true, ); diff --git a/packages/tonik_generate/test/src/response/response_class_generator_test.dart b/packages/tonik_generate/test/src/response/response_class_generator_test.dart index ae48ec6..f5438a5 100644 --- a/packages/tonik_generate/test/src/response/response_class_generator_test.dart +++ b/packages/tonik_generate/test/src/response/response_class_generator_test.dart @@ -80,21 +80,21 @@ void main() { // Required header field final xTestField = fields[0]; expect(xTestField.name, 'xTest'); - expect(xTestField.modifier, equals(FieldModifier.final$)); + expect(xTestField.modifier, FieldModifier.final$); expect(xTestField.type?.accept(emitter).toString(), 'String'); expect(xTestField.annotations.isEmpty, isTrue); // Optional header field final xOptionalField = fields[2]; expect(xOptionalField.name, 'xOptional'); - expect(xOptionalField.modifier, equals(FieldModifier.final$)); + expect(xOptionalField.modifier, FieldModifier.final$); expect(xOptionalField.type?.accept(emitter).toString(), 'int?'); expect(xOptionalField.annotations.isEmpty, isTrue); // Body field final bodyField = fields[1]; expect(bodyField.name, 'body'); - expect(bodyField.modifier, equals(FieldModifier.final$)); + expect(bodyField.modifier, FieldModifier.final$); expect(bodyField.type?.accept(emitter).toString(), 'String'); expect(bodyField.annotations.isEmpty, isTrue); @@ -158,14 +158,14 @@ void main() { final headerField = fields[0]; expect(headerField.name, 'bodyHeader'); expect(headerField.type?.accept(emitter).toString(), 'String'); - expect(headerField.modifier, equals(FieldModifier.final$)); + expect(headerField.modifier, FieldModifier.final$); expect(headerField.annotations.isEmpty, isTrue); // Body field should keep original name final bodyField = fields[1]; expect(bodyField.name, 'body'); expect(bodyField.type?.accept(emitter).toString(), 'int'); - expect(bodyField.modifier, equals(FieldModifier.final$)); + expect(bodyField.modifier, FieldModifier.final$); expect(bodyField.annotations.isEmpty, isTrue); // Verify constructor parameters maintain the same names diff --git a/packages/tonik_generate/test/src/response/response_generator_test.dart b/packages/tonik_generate/test/src/response/response_generator_test.dart index c1920a8..1c99560 100644 --- a/packages/tonik_generate/test/src/response/response_generator_test.dart +++ b/packages/tonik_generate/test/src/response/response_generator_test.dart @@ -84,7 +84,7 @@ void main() { ); final result = generator.generate(aliasResponse); - expect(result.filename, equals('alias_response.dart')); + expect(result.filename, 'alias_response.dart'); expect(result.code, contains('typedef AliasResponse =')); }); @@ -115,7 +115,7 @@ void main() { ); final result = generator.generate(response); - expect(result.filename, equals('single_body_response.dart')); + expect(result.filename, 'single_body_response.dart'); expect(result.code, contains('class SingleBodyResponse')); }); diff --git a/packages/tonik_generate/test/src/server/server_file_generator_test.dart b/packages/tonik_generate/test/src/server/server_file_generator_test.dart index 32de2f7..3015cab 100644 --- a/packages/tonik_generate/test/src/server/server_file_generator_test.dart +++ b/packages/tonik_generate/test/src/server/server_file_generator_test.dart @@ -109,7 +109,7 @@ void main() { final fileContent = File(generatedFile.path).readAsStringSync(); // Check file name - expect(actualFileName, equals('server.dart')); + expect(actualFileName, 'server.dart'); // Check file content expect(fileContent, contains('sealed class Server')); diff --git a/packages/tonik_generate/test/src/util/to_json_value_expression_generator_test.dart b/packages/tonik_generate/test/src/util/to_json_value_expression_generator_test.dart index f54b21a..2c7e9e9 100644 --- a/packages/tonik_generate/test/src/util/to_json_value_expression_generator_test.dart +++ b/packages/tonik_generate/test/src/util/to_json_value_expression_generator_test.dart @@ -53,7 +53,7 @@ void main() { ); expect( buildToJsonPropertyExpression('startTime', property), - 'startTime.toIso8601String()', + 'startTime.toTimeZonedIso8601String()', ); }); @@ -188,7 +188,7 @@ void main() { ); expect( buildToJsonPropertyExpression('meetingTimes', property), - 'meetingTimes.map((e) => e.toIso8601String()).toList()', + 'meetingTimes.map((e) => e.toTimeZonedIso8601String()).toList()', ); }); @@ -259,7 +259,7 @@ void main() { ); expect( buildToJsonPropertyExpression('createdAt', property), - 'createdAt.toIso8601String()', + 'createdAt.toTimeZonedIso8601String()', ); }); @@ -278,7 +278,7 @@ void main() { ); expect( buildToJsonPropertyExpression('updatedAt', property), - 'updatedAt?.toIso8601String()', + 'updatedAt?.toTimeZonedIso8601String()', ); }); @@ -497,7 +497,7 @@ void main() { ); expect( buildToJsonPropertyExpression('meetingTimes', property), - 'meetingTimes.map((e) => e.toIso8601String()).toList()', + 'meetingTimes.map((e) => e.toTimeZonedIso8601String()).toList()', ); }); @@ -519,7 +519,7 @@ void main() { ); expect( buildToJsonPropertyExpression('meetingTimes', property), - 'meetingTimes?.map((e) => e.toIso8601String()).toList()', + 'meetingTimes?.map((e) => e.toTimeZonedIso8601String()).toList()', ); }); @@ -626,7 +626,7 @@ void main() { ); expect( buildToJsonPathParameterExpression('startTime', parameter), - 'startTime.toIso8601String()', + 'startTime.toTimeZonedIso8601String()', ); }); @@ -713,7 +713,7 @@ void main() { ); expect( buildToJsonQueryParameterExpression('startTime', parameter), - 'startTime.toIso8601String()', + 'startTime.toTimeZonedIso8601String()', ); }); @@ -779,7 +779,7 @@ void main() { ); expect( buildToJsonHeaderParameterExpression('timestamp', parameter), - 'timestamp.toIso8601String()', + 'timestamp.toTimeZonedIso8601String()', ); }); diff --git a/packages/tonik_parse/test/model/model_alias_test.dart b/packages/tonik_parse/test/model/model_alias_test.dart index cf390d3..dfe2fd8 100644 --- a/packages/tonik_parse/test/model/model_alias_test.dart +++ b/packages/tonik_parse/test/model/model_alias_test.dart @@ -85,7 +85,7 @@ void main() { context: context, ); - expect(alias.resolved, equals(stringModel)); + expect(alias.resolved, stringModel); }); test('resolves single-level alias', () { @@ -102,7 +102,7 @@ void main() { context: context, ); - expect(outerAlias.resolved, equals(stringModel)); + expect(outerAlias.resolved, stringModel); }); test('resolves multi-level alias', () { @@ -124,7 +124,7 @@ void main() { context: context, ); - expect(level1.resolved, equals(stringModel)); + expect(level1.resolved, stringModel); }); }); } diff --git a/packages/tonik_parse/test/model/model_multiple_types_test.dart b/packages/tonik_parse/test/model/model_multiple_types_test.dart index d2f9dca..9ce32d2 100644 --- a/packages/tonik_parse/test/model/model_multiple_types_test.dart +++ b/packages/tonik_parse/test/model/model_multiple_types_test.dart @@ -66,7 +66,7 @@ void main() { expect(stringOrNumber.isNullable, isFalse); final oneOf = stringOrNumber.model as OneOfModel; - expect(oneOf.models.length, equals(2)); + expect(oneOf.models.length, 2); expect( oneOf.models.map((m) => m.model).toList(), containsAll([isA(), isA()]), @@ -109,7 +109,7 @@ void main() { expect(nullableMultiType.isNullable, isTrue); final oneOf = nullableMultiType.model as OneOfModel; - expect(oneOf.models.length, equals(2)); + expect(oneOf.models.length, 2); expect( oneOf.models.map((m) => m.model).toList(), containsAll([isA(), isA()]), @@ -128,7 +128,7 @@ void main() { expect(nullableMultiType.isNullable, isTrue); final oneOf = nullableMultiType.model as OneOfModel; - expect(oneOf.models.length, equals(2)); + expect(oneOf.models.length, 2); expect( oneOf.models.map((m) => m.model).toList(), containsAll([isA(), isA()]), @@ -150,7 +150,7 @@ void main() { expect(listModel.content, isA()); final content = listModel.content as OneOfModel; - expect(content.models.length, equals(2)); + expect(content.models.length, 2); expect( content.models.map((m) => m.model).toList(), containsAll([isA(), isA()]), diff --git a/packages/tonik_parse/test/model/model_property_test.dart b/packages/tonik_parse/test/model/model_property_test.dart index a1bc71d..a93c39f 100644 --- a/packages/tonik_parse/test/model/model_property_test.dart +++ b/packages/tonik_parse/test/model/model_property_test.dart @@ -118,7 +118,9 @@ void main() { final api = Importer().import(fileContent); final model = api.models.first as ClassModel; - final numberString = model.properties.firstWhere((p) => p.name == 'numberString'); + final numberString = model.properties.firstWhere( + (p) => p.name == 'numberString', + ); expect(numberString.model, isA()); }); diff --git a/packages/tonik_util/lib/src/decoding/datetime_decoding_extension.dart b/packages/tonik_util/lib/src/decoding/datetime_decoding_extension.dart new file mode 100644 index 0000000..adde16d --- /dev/null +++ b/packages/tonik_util/lib/src/decoding/datetime_decoding_extension.dart @@ -0,0 +1,286 @@ +import 'package:timezone/timezone.dart' as tz; +import 'package:tonik_util/src/decoding/decoding_exception.dart'; + +/// Extension on DateTime to provide timezone-aware parsing. +/// +/// This extension handles timezone information correctly: +/// - UTC strings return DateTime.utc objects +/// - Strings without timezone info return local DateTime objects +/// - Strings with timezone offsets return TZDateTime objects +extension DateTimeParsingExtension on DateTime { + /// Parses an ISO8601 datetime string with proper timezone handling. + /// + /// Returns: + /// - [DateTime] (UTC) for strings ending with 'Z' + /// - [DateTime] (local) for strings without timezone information + /// - [tz.TZDateTime] for strings with timezone offset information + /// + /// Throws [DecodingException] if the string is not a valid ISO8601 format. + static DateTime parseWithTimeZone(String input) { + if (input.isEmpty) { + throw const InvalidFormatException( + value: '', + format: 'ISO8601 datetime string', + ); + } + + // Handle different separator formats (T or space) + final normalizedInput = input.replaceFirst(' ', 'T'); + + // Check if it has timezone offset (±HH:MM or ±HHMM) + final timezoneRegex = RegExp(r'[+-]\d{2}:?\d{2}$'); + final timezoneMatch = timezoneRegex.firstMatch(normalizedInput); + + if (timezoneMatch != null) { + return _parseWithTimezoneOffset(normalizedInput, timezoneMatch); + } + + // Parse as UTC (ends with Z) or local time (no timezone info) + try { + return DateTime.parse(normalizedInput); + } on FormatException { + throw InvalidFormatException( + value: normalizedInput, + format: 'ISO8601 datetime format', + ); + } + } + + /// Parses a datetime string with timezone offset. + static tz.TZDateTime _parseWithTimezoneOffset( + String input, + RegExpMatch timezoneMatch, + ) { + final offsetString = timezoneMatch.group(0)!; + final datetimeString = input.substring( + 0, + input.length - offsetString.length, + ); + + final offset = _parseTimezoneOffset(offsetString); + final localDateTime = DateTime.parse(datetimeString); + final location = _findLocationForOffset(offset, localDateTime); + + // For standard offsets that have proper timezone locations, use them + if (location.name != 'UTC' || offset == Duration.zero) { + final utcDateTime = localDateTime.subtract(offset); + + final utcTz = tz.TZDateTime.utc( + utcDateTime.year, + utcDateTime.month, + utcDateTime.day, + utcDateTime.hour, + utcDateTime.minute, + utcDateTime.second, + utcDateTime.millisecond, + utcDateTime.microsecond, + ); + + return tz.TZDateTime.from(utcTz, location); + } + + // For unusual offsets that don't have proper timezone locations, + // fall back to UTC and convert the time correctly + final utcDateTime = localDateTime.subtract(offset); + + return tz.TZDateTime.utc( + utcDateTime.year, + utcDateTime.month, + utcDateTime.day, + utcDateTime.hour, + utcDateTime.minute, + utcDateTime.second, + utcDateTime.millisecond, + utcDateTime.microsecond, + ); + } + + /// Finds the best timezone location for a given offset at a + /// specific datetime. + /// + /// This leverages the timezone package's comprehensive database to find + /// locations that match the offset, taking into account DST changes. + static tz.Location _findLocationForOffset( + Duration offset, + DateTime dateTime, + ) { + final offsetMinutes = offset.inMinutes; + final timestamp = dateTime.millisecondsSinceEpoch; + + for (final locationName in _commonLocations) { + try { + final location = tz.getLocation(locationName); + final timeZone = location.timeZone(timestamp); + if (timeZone.offset == offsetMinutes * 60 * 1000) { + return location; + } + } on tz.LocationNotFoundException { + // Location doesn't exist, continue + } + } + + final matchingLocations = []; + for (final location in tz.timeZoneDatabase.locations.values) { + final timeZone = location.timeZone(timestamp); + if (timeZone.offset == offsetMinutes * 60 * 1000) { + matchingLocations.add(location); + } + } + + if (matchingLocations.isNotEmpty) { + // Prefer locations that don't use deprecated prefixes + final preferredMatches = + matchingLocations + .where( + (loc) => + !loc.name.startsWith('US/') && + !loc.name.startsWith('Etc/') && + !loc.name.contains('GMT'), + ) + .toList(); + + if (preferredMatches.isNotEmpty) { + return preferredMatches.first; + } + + return matchingLocations.first; + } + + return _createFixedOffsetLocation(offset); + } + + /// Creates a location with a fixed offset when no matching timezone is found. + static tz.Location _createFixedOffsetLocation(Duration offset) { + final offsetMinutes = offset.inMinutes; + + // For standard hour offsets, try to use Etc/GMT locations + if (offsetMinutes % 60 == 0) { + final offsetHours = offsetMinutes ~/ 60; + // Use Etc/GMT format which is supported by the timezone database + // Note: Etc/GMT offsets are inverted (Etc/GMT+5 is actually GMT-5) + final etcName = + offset.isNegative + ? 'Etc/GMT+${offsetHours.abs()}' + : 'Etc/GMT-${offsetHours.abs()}'; + + try { + return tz.getLocation(etcName); + } on tz.LocationNotFoundException { + // Fall through to UTC + } + } + + // For non-standard offsets, fall back to UTC + // This is a limitation - the timezone package doesn't easily support + // arbitrary fixed offsets, so we use UTC as fallback + return tz.getLocation('UTC'); + } + + /// Parses timezone offset string (±HH:MM or ±HHMM) into Duration. + static Duration _parseTimezoneOffset(String offsetString) { + // Remove optional colon for compact format + final normalized = offsetString.replaceAll(':', ''); + + if (normalized.length != 5) { + throw InvalidFormatException( + value: offsetString, + format: '±HHMM or ±HH:MM timezone offset', + ); + } + + final sign = normalized[0] == '+' ? 1 : -1; + final hoursStr = normalized.substring(1, 3); + final minutesStr = normalized.substring(3, 5); + + final hours = int.parse(hoursStr); + final minutes = int.parse(minutesStr); + + // Let Duration handle any overflow gracefully + return Duration(hours: sign * hours, minutes: sign * minutes); + } +} + +/// Commonly used timezone locations, prioritized for offset matching. +/// Based on major cities and avoiding deprecated location names. +const _commonLocations = [ + // Europe + 'Europe/London', + 'Europe/Paris', + 'Europe/Berlin', + 'Europe/Rome', + 'Europe/Madrid', + 'Europe/Amsterdam', + 'Europe/Brussels', + 'Europe/Vienna', + 'Europe/Zurich', + 'Europe/Stockholm', + 'Europe/Oslo', + 'Europe/Copenhagen', + 'Europe/Helsinki', + 'Europe/Warsaw', + 'Europe/Prague', + 'Europe/Budapest', + 'Europe/Athens', + 'Europe/Istanbul', + 'Europe/Moscow', + + // Americas + 'America/New_York', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', + 'America/Toronto', + 'America/Vancouver', + 'America/Montreal', + 'America/Mexico_City', + 'America/Sao_Paulo', + 'America/Buenos_Aires', + 'America/Lima', + 'America/Bogota', + 'America/Caracas', + 'America/Santiago', + 'America/Montevideo', + + // Asia + 'Asia/Tokyo', + 'Asia/Seoul', + 'Asia/Shanghai', + 'Asia/Hong_Kong', + 'Asia/Singapore', + 'Asia/Bangkok', + 'Asia/Jakarta', + 'Asia/Manila', + 'Asia/Kuala_Lumpur', + 'Asia/Kolkata', + 'Asia/Mumbai', + 'Asia/Karachi', + 'Asia/Dubai', + 'Asia/Riyadh', + 'Asia/Baghdad', + 'Asia/Tehran', + 'Asia/Kabul', + 'Asia/Tashkent', + 'Asia/Almaty', + + // Australia & Pacific + 'Australia/Sydney', + 'Australia/Melbourne', + 'Australia/Brisbane', + 'Australia/Perth', + 'Australia/Adelaide', + 'Pacific/Auckland', + 'Pacific/Honolulu', + 'Pacific/Fiji', + + // Africa + 'Africa/Cairo', + 'Africa/Johannesburg', + 'Africa/Lagos', + 'Africa/Nairobi', + 'Africa/Casablanca', + 'Africa/Tunis', + 'Africa/Algiers', + + // UTC + 'UTC', +]; diff --git a/packages/tonik_util/lib/src/decoding/json_decoder.dart b/packages/tonik_util/lib/src/decoding/json_decoder.dart index 957f61d..eae0956 100644 --- a/packages/tonik_util/lib/src/decoding/json_decoder.dart +++ b/packages/tonik_util/lib/src/decoding/json_decoder.dart @@ -1,10 +1,11 @@ import 'package:big_decimal/big_decimal.dart'; import 'package:tonik_util/src/date.dart'; +import 'package:tonik_util/src/decoding/datetime_decoding_extension.dart'; import 'package:tonik_util/src/decoding/decoding_exception.dart'; /// Extensions for decoding JSON values. extension JsonDecoder on Object? { - /// Decodes a JSON value to a DateTime. + /// Decodes a JSON value to a DateTime with timezone awareness. /// /// Expects ISO 8601 format string. /// Throws [InvalidTypeException] if the value is not a valid date string @@ -25,7 +26,7 @@ extension JsonDecoder on Object? { ); } try { - return DateTime.parse(this! as String); + return DateTimeParsingExtension.parseWithTimeZone(this! as String); } on FormatException catch (e) { throw InvalidTypeException( value: this! as String, diff --git a/packages/tonik_util/lib/src/decoding/simple_decoder.dart b/packages/tonik_util/lib/src/decoding/simple_decoder.dart index 9f7d9a8..151b593 100644 --- a/packages/tonik_util/lib/src/decoding/simple_decoder.dart +++ b/packages/tonik_util/lib/src/decoding/simple_decoder.dart @@ -1,5 +1,6 @@ import 'package:big_decimal/big_decimal.dart'; import 'package:tonik_util/src/date.dart'; +import 'package:tonik_util/src/decoding/datetime_decoding_extension.dart'; import 'package:tonik_util/src/decoding/decoding_exception.dart'; /// Extensions for decoding simple form values from strings. @@ -131,7 +132,7 @@ extension SimpleDecoder on String? { return decodeSimpleBool(context: context); } - /// Decodes a string to a DateTime. + /// Decodes a string to a DateTime with timezone awareness. /// /// Expects ISO 8601 format. /// Throws [InvalidTypeException] if the string is not a valid date @@ -145,7 +146,7 @@ extension SimpleDecoder on String? { ); } try { - return DateTime.parse(this!); + return DateTimeParsingExtension.parseWithTimeZone(this!); } on Object { throw InvalidTypeException( value: this!, diff --git a/packages/tonik_util/lib/src/encoding/base_encoder.dart b/packages/tonik_util/lib/src/encoding/base_encoder.dart index 145cefa..eb21e97 100644 --- a/packages/tonik_util/lib/src/encoding/base_encoder.dart +++ b/packages/tonik_util/lib/src/encoding/base_encoder.dart @@ -1,5 +1,5 @@ import 'package:meta/meta.dart'; -import 'package:tonik_util/src/encoding/encoding_exception.dart'; +import 'package:tonik_util/tonik_util.dart'; /// Base class for OpenAPI parameter style encoders. /// @@ -84,7 +84,7 @@ abstract class BaseEncoder { } if (value is DateTime) { - return value.toIso8601String(); + return value.toTimeZonedIso8601String(); } return value.toString(); diff --git a/packages/tonik_util/lib/src/encoding/datetime_extension.dart b/packages/tonik_util/lib/src/encoding/datetime_extension.dart new file mode 100644 index 0000000..0561254 --- /dev/null +++ b/packages/tonik_util/lib/src/encoding/datetime_extension.dart @@ -0,0 +1,60 @@ +/// Extension on DateTime to provide timezone-aware encoding. +/// +/// This extension ensures that DateTime objects are encoded with their full +/// timezone information, unlike [DateTime.toIso8601String()] which only +/// works correctly for UTC dates. +extension DateTimeEncodingExtension on DateTime { + /// Encodes this DateTime to a string representation that preserves + /// timezone information. + /// + /// For UTC dates, this returns the same as [DateTime.toIso8601String()]. + /// For local dates, this ensures the timezone offset is properly included. + String toTimeZonedIso8601String() { + if (isUtc) { + // For UTC dates, use toIso8601String which works correctly + return toIso8601String(); + } + + // For local dates, we need to include timezone offset + // Format the base date and time manually to match toIso8601String format + final year = this.year; + final month = _twoDigits(this.month); + final day = _twoDigits(this.day); + final hour = _twoDigits(this.hour); + final minute = _twoDigits(this.minute); + final second = _twoDigits(this.second); + + // Add milliseconds if present + final millisecondString = millisecond > 0 + ? '.${_threeDigits(millisecond)}' + : ''; + + // Add microseconds if present + final microsecondString = microsecond > 0 + ? _threeDigits(microsecond) + : ''; + + // Get the timezone offset in hours and minutes + final offset = timeZoneOffset; + final offsetHours = offset.inHours.abs(); + final offsetMinutes = offset.inMinutes.abs() % 60; + + // Format timezone offset + final offsetSign = offset.isNegative ? '-' : '+'; + final offsetString = '$offsetSign' + '${_twoDigits(offsetHours)}:${_twoDigits(offsetMinutes)}'; + + return '$year-$month-${day}T$hour:$minute:$second' + '$millisecondString$microsecondString$offsetString'; + } + + /// Formats a number as two digits with leading zero if needed. + static String _twoDigits(int n) { + return n.toString().padLeft(2, '0'); + } + + /// Formats a number as three digits with leading zeros if needed. + static String _threeDigits(int n) { + return n.toString().padLeft(3, '0'); + } +} diff --git a/packages/tonik_util/lib/tonik_util.dart b/packages/tonik_util/lib/tonik_util.dart index 936c273..98bbe84 100644 --- a/packages/tonik_util/lib/tonik_util.dart +++ b/packages/tonik_util/lib/tonik_util.dart @@ -6,6 +6,7 @@ export 'src/decoding/decoding_exception.dart'; export 'src/decoding/json_decoder.dart'; export 'src/decoding/simple_decoder.dart'; export 'src/dio/server_config.dart'; +export 'src/encoding/datetime_extension.dart'; export 'src/encoding/deep_object_encoder.dart'; export 'src/encoding/delimited_encoder.dart'; export 'src/encoding/encoding_exception.dart'; diff --git a/packages/tonik_util/pubspec.yaml b/packages/tonik_util/pubspec.yaml index 2bccc0a..ac77faa 100644 --- a/packages/tonik_util/pubspec.yaml +++ b/packages/tonik_util/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: collection: ^1.19.1 dio: ^5.0.0 meta: ^1.16.0 + timezone: ^0.10.1 dev_dependencies: test: ^1.24.0 diff --git a/packages/tonik_util/test/src/decoding/datetime_decoding_extension_test.dart b/packages/tonik_util/test/src/decoding/datetime_decoding_extension_test.dart new file mode 100644 index 0000000..ed6b868 --- /dev/null +++ b/packages/tonik_util/test/src/decoding/datetime_decoding_extension_test.dart @@ -0,0 +1,548 @@ +import 'package:test/test.dart'; +import 'package:timezone/data/latest.dart' as tz; +import 'package:tonik_util/src/decoding/datetime_decoding_extension.dart'; +import 'package:tonik_util/src/decoding/decoding_exception.dart'; + +void main() { + setUpAll(tz.initializeTimeZones); + + group('DateTimeParsingExtension', () { + group('parseWithTimeZone', () { + group('UTC parsing', () { + test('parses UTC datetime with Z suffix', () { + const input = '2023-12-25T15:30:45Z'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.isUtc, isTrue); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 0); + expect(result.microsecond, 0); + expect(result.timeZoneOffset, Duration.zero); + }); + + test('parses UTC datetime with milliseconds', () { + const input = '2023-12-25T15:30:45.123Z'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.isUtc, isTrue); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 123); + expect(result.microsecond, 0); + expect(result.timeZoneOffset, Duration.zero); + }); + + test('parses UTC datetime with microseconds', () { + const input = '2023-12-25T15:30:45.123456Z'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.isUtc, isTrue); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 123); + expect(result.microsecond, 456); + expect(result.timeZoneOffset, Duration.zero); + }); + + test('parses UTC datetime at midnight', () { + const input = '2023-12-25T00:00:00Z'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.isUtc, isTrue); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 0); + expect(result.minute, 0); + expect(result.second, 0); + expect(result.millisecond, 0); + expect(result.microsecond, 0); + expect(result.timeZoneOffset, Duration.zero); + }); + + test('parses UTC datetime at end of day', () { + const input = '2023-12-25T23:59:59Z'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.isUtc, isTrue); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 23); + expect(result.minute, 59); + expect(result.second, 59); + expect(result.millisecond, 0); + expect(result.microsecond, 0); + expect(result.timeZoneOffset, Duration.zero); + }); + }); + + group('local time parsing (no timezone info)', () { + test('parses datetime without timezone as local time', () { + const input = '2023-12-25T15:30:45'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + final expected = DateTime(2023, 12, 25, 15, 30, 45); + + expect(result.isUtc, isFalse); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 0); + expect(result.microsecond, 0); + expect(result.timeZoneOffset, expected.timeZoneOffset); + }); + + test('parses datetime with milliseconds as local time', () { + const input = '2023-12-25T15:30:45.123'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + final expected = DateTime(2023, 12, 25, 15, 30, 45, 123); + + expect(result.isUtc, isFalse); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 123); + expect(result.microsecond, 0); + expect(result.timeZoneOffset, expected.timeZoneOffset); + }); + + test('parses datetime with microseconds as local time', () { + const input = '2023-12-25T15:30:45.123456'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + final expected = DateTime(2023, 12, 25, 15, 30, 45, 123, 456); + + expect(result.isUtc, isFalse); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 123); + expect(result.microsecond, 456); + expect(result.timeZoneOffset, expected.timeZoneOffset); + }); + + test('parses date-only format as local midnight', () { + const input = '2023-12-25'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + final expected = DateTime(2023, 12, 25); + + expect(result.isUtc, isFalse); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 0); + expect(result.minute, 0); + expect(result.second, 0); + expect(result.millisecond, 0); + expect(result.microsecond, 0); + expect(result.timeZoneOffset, expected.timeZoneOffset); + }); + }); + + group('timezone offset parsing', () { + test('parses positive timezone offset (+05:00)', () { + const input = '2023-12-25T15:30:45+05:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 0); + expect(result.microsecond, 0); + expect(result.timeZoneOffset.inHours, 5); + }); + + test('parses negative timezone offset (-08:00)', () { + const input = '2023-12-25T15:30:45-08:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 0); + expect(result.microsecond, 0); + expect(result.timeZoneOffset.inHours, -8); + }); + + test('parses timezone offset with 30-minute offset (+05:30)', () { + const input = '2023-12-25T15:30:45+05:30'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 0); + expect(result.microsecond, 0); + expect(result.timeZoneOffset.inMinutes, 330); // 5.5 hours + }); + + test('parses timezone offset with 45-minute offset (+05:45)', () { + const input = '2023-12-25T15:30:45+05:45'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 0); + expect(result.microsecond, 0); + expect(result.timeZoneOffset.inMinutes, 345); // 5.75 hours + }); + + test('parses timezone offset with milliseconds', () { + const input = '2023-12-25T15:30:45.123+05:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 123); + expect(result.microsecond, 0); + expect(result.timeZoneOffset.inHours, 5); + }); + + test('parses timezone offset with microseconds', () { + const input = '2023-12-25T15:30:45.123456+05:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 123); + expect(result.microsecond, 456); + expect(result.timeZoneOffset.inHours, 5); + }); + + test('parses compact timezone offset format (+0500)', () { + const input = '2023-12-25T15:30:45+0500'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 0); + expect(result.microsecond, 0); + expect(result.timeZoneOffset.inHours, 5); + }); + + test('parses compact negative timezone offset format (-0800)', () { + const input = '2023-12-25T15:30:45-0800'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 0); + expect(result.microsecond, 0); + expect(result.timeZoneOffset.inHours, -8); + }); + }); + + group('timezone location matching', () { + test('maps common European timezone offset to CET', () { + const input = '2023-12-25T15:30:45+01:00'; // CET (winter time) + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.timeZoneName, 'CET'); + expect(result.timeZoneOffset.inHours, 1); + }); + + test('maps summer time European offset to CEST', () { + const input = '2023-07-25T15:30:45+02:00'; // CEST (summer time) + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.timeZoneName, 'CEST'); + expect(result.timeZoneOffset.inHours, 2); + }); + + test('maps US Eastern timezone offset to EST', () { + const input = '2023-12-25T15:30:45-05:00'; // EST (winter time) + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.timeZoneName, 'EST'); + expect(result.timeZoneOffset.inHours, -5); + }); + + test('maps US Eastern summer time offset to EDT', () { + const input = '2023-07-25T15:30:45-04:00'; // EDT (summer time) + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.timeZoneName, 'EDT'); + expect(result.timeZoneOffset.inHours, -4); + }); + + test('maps India Standard Time offset to IST', () { + const input = '2023-12-25T15:30:45+05:30'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + expect(result.timeZoneName, 'IST'); + expect(result.timeZoneOffset.inMinutes, 330); // 5.5 hours + }); + + test('maps Japan Standard Time offset to JST', () { + const input = '2023-12-25T15:30:45+09:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.timeZoneName, 'JST'); + expect(result.timeZoneOffset.inHours, 9); + }); + + test( + 'handles unusual timezone offset by falling back to UTC', + () { + const input = '2023-12-25T15:30:45+03:17'; // Unusual offset + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + // For unusual offsets, falls back to UTC due to + // timezone package limitations + expect(result.timeZoneOffset.inMinutes, 0); // UTC + expect(result.timeZoneName, 'UTC'); + + // But the parsed time should still be correctly converted + // Original: 15:30:45+03:17 should convert to UTC time + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 12); // 15:30 - 3:17 = 12:13 + expect(result.minute, 13); + expect(result.second, 45); + }, + ); + }); + + group('edge cases', () { + test('parses leap year date (UTC)', () { + const input = '2024-02-29T12:00:00Z'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2024); + expect(result.month, 2); + expect(result.day, 29); + }); + + test('parses leap year date (local)', () { + const input = '2024-02-29T12:00:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2024); + expect(result.month, 2); + expect(result.day, 29); + }); + + test('parses leap year date (timezone offset)', () { + const input = '2024-02-29T12:00:00+03:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2024); + expect(result.month, 2); + expect(result.day, 29); + }); + + test('parses year boundaries correctly (UTC)', () { + const input = '2023-12-31T23:59:59Z'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 31); + }); + + test('parses year boundaries correctly (local)', () { + const input = '2023-12-31T23:59:59'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 31); + }); + + test('parses year boundaries correctly (timezone offset)', () { + const input = '2023-12-31T23:59:59-05:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 31); + }); + + test('parses new year correctly (UTC)', () { + const input = '2024-01-01T00:00:00Z'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2024); + expect(result.month, 1); + expect(result.day, 1); + }); + + test('parses new year correctly (local)', () { + const input = '2024-01-01T00:00:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2024); + expect(result.month, 1); + expect(result.day, 1); + }); + + test('parses new year correctly (timezone offset)', () { + const input = '2024-01-01T00:00:00+09:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2024); + expect(result.month, 1); + expect(result.day, 1); + }); + + test('handles single digit milliseconds (UTC)', () { + const input = '2023-12-25T15:30:45.1Z'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.millisecond, 100); + }); + + test('handles single digit milliseconds (local)', () { + const input = '2023-12-25T15:30:45.1'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.millisecond, 100); + }); + + test('handles single digit milliseconds (timezone offset)', () { + const input = '2023-12-25T15:30:45.1+02:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.millisecond, 100); + }); + + test('handles two digit milliseconds (UTC)', () { + const input = '2023-12-25T15:30:45.12Z'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.millisecond, 120); + }); + + test('handles two digit milliseconds (local)', () { + const input = '2023-12-25T15:30:45.12'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.millisecond, 120); + }); + + test('handles two digit milliseconds (timezone offset)', () { + const input = '2023-12-25T15:30:45.12-07:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.millisecond, 120); + }); + }); + + group('error handling', () { + test('throws InvalidFormatException for invalid format', () { + const input = 'invalid-date-format'; + expect( + () => DateTimeParsingExtension.parseWithTimeZone(input), + throwsA(isA()), + ); + }); + + test('throws InvalidFormatException for incomplete date', () { + const input = '2023-12'; + expect( + () => DateTimeParsingExtension.parseWithTimeZone(input), + throwsA(isA()), + ); + }); + }); + + group('RFC3339 compliance', () { + test('parses full RFC3339 format with T separator', () { + const input = '2023-12-25T15:30:45.123456+05:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 123); + expect(result.microsecond, 456); + expect(result.timeZoneOffset.inHours, 5); + }); + + test('parses RFC3339 format with space separator', () { + const input = '2023-12-25 15:30:45.123+05:00'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 123); + expect(result.microsecond, 0); + expect(result.timeZoneOffset.inHours, 5); + }); + + test('parses minimum required RFC3339 format', () { + const input = '2023-12-25T15:30:45Z'; + final result = DateTimeParsingExtension.parseWithTimeZone(input); + + expect(result.isUtc, isTrue); + expect(result.year, 2023); + expect(result.month, 12); + expect(result.day, 25); + expect(result.hour, 15); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 0); + expect(result.microsecond, 0); + expect(result.timeZoneOffset, Duration.zero); + }); + }); + }); + }); +} diff --git a/packages/tonik_util/test/src/decoding/json_decoder_test.dart b/packages/tonik_util/test/src/decoding/json_decoder_test.dart index 1b21dc2..e474fcc 100644 --- a/packages/tonik_util/test/src/decoding/json_decoder_test.dart +++ b/packages/tonik_util/test/src/decoding/json_decoder_test.dart @@ -2,16 +2,36 @@ import 'dart:convert'; import 'package:big_decimal/big_decimal.dart'; import 'package:test/test.dart'; +import 'package:timezone/data/latest.dart' as tz; +import 'package:timezone/timezone.dart' as tz; import 'package:tonik_util/src/date.dart'; import 'package:tonik_util/src/decoding/decoding_exception.dart'; import 'package:tonik_util/src/decoding/json_decoder.dart'; void main() { + setUpAll(tz.initializeTimeZones); group('JsonDecoder', () { group('DateTime', () { - test('decodes DateTime values', () { - final date = DateTime.utc(2024, 3, 14); - expect(date.toIso8601String().decodeJsonDateTime(), date); + test('decodes DateTime values with timezone awareness', () { + // Test UTC parsing + const utcString = '2024-03-14T10:30:45Z'; + final utcResult = utcString.decodeJsonDateTime(); + expect(utcResult.isUtc, isTrue); + expect(utcResult, DateTime.utc(2024, 3, 14, 10, 30, 45)); + + // Test local time parsing + const localString = '2024-03-14T10:30:45'; + final localResult = localString.decodeJsonDateTime(); + expect(localResult.isUtc, isFalse); + expect(localResult, DateTime(2024, 3, 14, 10, 30, 45)); + + // Test timezone offset parsing + const offsetString = '2024-03-14T10:30:45+05:00'; + final offsetResult = offsetString.decodeJsonDateTime(); + expect(offsetResult, isA()); + expect(offsetResult.timeZoneOffset.inHours, 5); + + // Test error cases expect( () => 123.decodeJsonDateTime(), throwsA(isA()), @@ -22,9 +42,11 @@ void main() { ); }); - test('decodes nullable DateTime values', () { - final date = DateTime.utc(2024, 3, 14); - expect(date.toIso8601String().decodeJsonNullableDateTime(), date); + test('decodes nullable DateTime values with timezone awareness', () { + expect( + '2024-03-14T10:30:45Z'.decodeJsonNullableDateTime(), + DateTime.utc(2024, 3, 14, 10, 30, 45), + ); expect(null.decodeJsonNullableDateTime(), isNull); expect(''.decodeJsonNullableDateTime(), isNull); expect( @@ -317,7 +339,7 @@ void main() { group('decodeMap', () { test('decodes valid map', () { final map = {'key': 'value'}; - expect(map.decodeMap(), equals(map)); + expect(map.decodeMap(), map); }); test('throws on null', () { diff --git a/packages/tonik_util/test/src/decoding/simple_decoder_test.dart b/packages/tonik_util/test/src/decoding/simple_decoder_test.dart index 366b6a1..65fceb4 100644 --- a/packages/tonik_util/test/src/decoding/simple_decoder_test.dart +++ b/packages/tonik_util/test/src/decoding/simple_decoder_test.dart @@ -1,10 +1,13 @@ import 'package:big_decimal/big_decimal.dart'; import 'package:test/test.dart'; +import 'package:timezone/data/latest.dart' as tz; +import 'package:timezone/timezone.dart' as tz; import 'package:tonik_util/src/date.dart'; import 'package:tonik_util/src/decoding/decoding_exception.dart'; import 'package:tonik_util/src/decoding/simple_decoder.dart'; void main() { + setUpAll(tz.initializeTimeZones); group('SimpleDecoder', () { group('Simple Values', () { test('decodes integer values', () { @@ -46,9 +49,28 @@ void main() { ); }); - test('decodes DateTime values', () { - final date = DateTime.utc(2024, 3, 14); - expect(date.toIso8601String().decodeSimpleDateTime(), date); + test('decodes DateTime values with timezone awareness', () { + // Test UTC parsing + final utcDate = DateTime.utc(2024, 3, 14, 10, 30, 45); + const utcString = '2024-03-14T10:30:45Z'; + final utcResult = utcString.decodeSimpleDateTime(); + expect(utcResult.isUtc, isTrue); + expect(utcResult, utcDate); + + // Test local time parsing + const localString = '2024-03-14T10:30:45'; + final localResult = localString.decodeSimpleDateTime(); + final expectedLocal = DateTime(2024, 3, 14, 10, 30, 45); + expect(localResult.isUtc, isFalse); + expect(localResult, expectedLocal); + + // Test timezone offset parsing + const offsetString = '2024-03-14T10:30:45+05:00'; + final offsetResult = offsetString.decodeSimpleDateTime(); + expect(offsetResult, isA()); + expect(offsetResult.timeZoneOffset.inHours, 5); + + // Test error cases expect( () => 'not-a-date'.decodeSimpleDateTime(), throwsA(isA()), @@ -128,8 +150,8 @@ void main() { expect('3.14'.decodeSimpleNullableDouble(), 3.14); expect('true'.decodeSimpleNullableBool(), isTrue); expect( - '2024-03-14T00:00:00.000Z'.decodeSimpleNullableDateTime(), - DateTime.utc(2024, 3, 14), + '2024-03-14T10:30:45Z'.decodeSimpleNullableDateTime(), + DateTime.utc(2024, 3, 14, 10, 30, 45), ); expect( '3.14'.decodeSimpleNullableBigDecimal(), diff --git a/packages/tonik_util/test/src/encoder/datetime_extension_test.dart b/packages/tonik_util/test/src/encoder/datetime_extension_test.dart new file mode 100644 index 0000000..77be07d --- /dev/null +++ b/packages/tonik_util/test/src/encoder/datetime_extension_test.dart @@ -0,0 +1,162 @@ +import 'package:test/test.dart'; +import 'package:timezone/data/latest.dart' as tz; +import 'package:timezone/timezone.dart' as tz; +import 'package:tonik_util/src/encoding/datetime_extension.dart'; + +void main() { + setUpAll(tz.initializeTimeZones); + + group('DateTimeEncodingExtension', () { + group('toTimeZonedIso8601String', () { + test('encodes UTC DateTime correctly', () { + final utcDateTime = DateTime.utc(2023, 12, 25, 15, 30, 45, 123); + final result = utcDateTime.toTimeZonedIso8601String(); + + // Should match toIso8601String for UTC dates + expect(result, utcDateTime.toIso8601String()); + expect(result, '2023-12-25T15:30:45.123Z'); + }); + }); + + group('timezone handling', () { + test('encodes in EST (UTC-5:00)', () { + final estLocation = tz.getLocation('America/New_York'); + final estDateTime = tz.TZDateTime( + estLocation, + 2023, + 12, + 25, + 15, + 30, + 45, + ); + + final result = estDateTime.toTimeZonedIso8601String(); + + // Should include EST timezone offset (-05:00) + expect(result, '2023-12-25T15:30:45-05:00'); + }); + + test('encodes in PST (UTC-8:00)', () { + final pstLocation = tz.getLocation('America/Los_Angeles'); + final pstDateTime = tz.TZDateTime( + pstLocation, + 2023, + 12, + 25, + 18, + 30, + 45, + ); + + final result = pstDateTime.toTimeZonedIso8601String(); + + // Should include PST timezone offset (-08:00) + expect(result, '2023-12-25T18:30:45-08:00'); + }); + + test('encodes in IST (UTC+5:30)', () { + final istLocation = tz.getLocation('Asia/Kolkata'); + final istDateTime = tz.TZDateTime( + istLocation, + 2023, + 12, + 25, + 20, + 0, + 45, + ); + + final result = istDateTime.toTimeZonedIso8601String(); + + // Should include IST timezone offset (+05:30) + expect(result, '2023-12-25T20:00:45+05:30'); + }); + + test('encodes in CET (UTC+1:00)', () { + final cetLocation = tz.getLocation('Europe/Paris'); + final cetDateTime = tz.TZDateTime( + cetLocation, + 2023, + 12, + 25, + 16, + 30, + 45, + ); + + final result = cetDateTime.toTimeZonedIso8601String(); + + // Should include CET timezone offset (+01:00) + expect(result, '2023-12-25T16:30:45+01:00'); + }); + + test('encodes in GMT (UTC+0:00)', () { + final gmtLocation = tz.getLocation('Europe/London'); + final gmtDateTime = tz.TZDateTime( + gmtLocation, + 2023, + 12, + 25, + 15, + 30, + 45, + ); + + final result = gmtDateTime.toTimeZonedIso8601String(); + + // Should include GMT timezone offset (+00:00) + expect(result, '2023-12-25T15:30:45+00:00'); + }); + + test('encodes with milliseconds in timezone', () { + final estLocation = tz.getLocation('America/New_York'); + final estDateTime = tz.TZDateTime( + estLocation, + 2023, + 12, + 25, + 15, + 30, + 45, + 123, + ); + + final result = estDateTime.toTimeZonedIso8601String(); + + // Should include EST timezone offset (-05:00) and milliseconds + expect(result, '2023-12-25T15:30:45.123-05:00'); + }); + + test('encodes with microseconds in timezone', () { + final pstLocation = tz.getLocation('America/Los_Angeles'); + final pstDateTime = tz.TZDateTime( + pstLocation, + 2023, + 12, + 25, + 18, + 30, + 45, + 123, + 456, + ); + + final result = pstDateTime.toTimeZonedIso8601String(); + + // Should include PST timezone offset (-08:00) and microseconds + expect(result, '2023-12-25T18:30:45.123456-08:00'); + }); + + test('encodes in JST (UTC+9:00)', () { + final jstLocation = tz.getLocation('Asia/Tokyo'); + final jstDateTime = tz.TZDateTime(jstLocation, 2009, 6, 30, 18, 30); + + final result = jstDateTime.toTimeZonedIso8601String(); + + // Should include JST timezone offset (+09:00) + expect(result, '2009-06-30T18:30:00+09:00'); + }); + }); + }); +}