diff --git a/MIGRATION.md b/MIGRATION.md index ac29c46ca..7b87bebde 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -113,6 +113,78 @@ await catalog.loadTableResult( ); ``` +### Timestamps are `DateTime` instead of `String` or `int` + +Every timestamp the SDK returns is now a `DateTime` in UTC, parsed once when the response is +decoded, instead of a raw ISO 8601 `String` or a Unix timestamp `int`. Comparing, formatting and +doing arithmetic on them no longer requires parsing them yourself. + +| Type | Fields | Before | After | +| --- | --- | --- | --- | +| `Session` | `expiresAt` | `int?` (Unix seconds) | `DateTime?` | +| `User` | `createdAt` | `String` | `DateTime` | +| `User` | `confirmationSentAt`, `recoverySentAt`, `emailChangeSentAt`, `invitedAt`, `emailConfirmedAt`, `phoneConfirmedAt`, `lastSignInAt`, `updatedAt` | `String?` | `DateTime?` | +| `UserIdentity` | `createdAt`, `lastSignInAt`, `updatedAt` | `String?` | `DateTime?` | +| `OAuthClient` | `createdAt`, `updatedAt` | `String` | `DateTime` | +| `Bucket` | `createdAt`, `updatedAt` | `String` | `DateTime` | +| `FileObject` | `createdAt`, `updatedAt` | `String?` | `DateTime?` | +| `FileObjectV2` | `createdAt` | `String` | `DateTime` | +| `FileObjectV2` | `updatedAt`, `lastModified` | `String?` | `DateTime?` | +| `PaginatedFile` | `createdAt`, `updatedAt` | `String?` | `DateTime?` | + +```dart +// Before +final expiresAt = supabase.auth.currentSession?.expiresAt; +final expiry = expiresAt == null + ? null + : DateTime.fromMillisecondsSinceEpoch(expiresAt * 1000); +final createdAt = DateTime.parse(user.createdAt); + +// After +final expiry = supabase.auth.currentSession?.expiresAt; +final createdAt = user.createdAt; +``` + +If you need the previous representation, ask for it explicitly: + +```dart +final isoString = user.createdAt.toIso8601String(); +final unixSeconds = session.expiresAt!.millisecondsSinceEpoch ~/ 1000; +``` + +The wire format is unchanged. Of the types above only `Session`, `User` and `UserIdentity` have a +`toJson()`, and those still write ISO 8601 strings for the `User` and `UserIdentity` timestamps and +Unix seconds for `Session.expires_at`, so sessions persisted by v2 are still readable by v3. The +storage types have no `toJson()`, in v2 or v3. + +Three behavioural details are worth checking: + +- The `DateTime` values are in UTC. `DateTime` equality takes the time zone flag into account, so + compare against `DateTime.utc(...)` rather than `DateTime(...)`, or call `toLocal()` first. +- A timestamp the server is documented to always send is now parsed strictly. `User.createdAt` + used to fall back to an empty string when the field was missing and now throws a + `FormatException`, which surfaces a malformed payload instead of passing an unusable value on. +- A timestamp naming a date that does not exist is rejected rather than rolled forward. + `DateTime.parse` reads `2019-02-29` as 1 March 2019; parsing now throws a `FormatException` + instead. + +### `OAuthAuthorizationDetailsResponse.user` is an `OAuthAuthorizingUser` + +The OAuth 2.1 server returns only an id and an email for the user a pending authorization request +belongs to, so `OAuthAuthorizationDetailsResponse.user` was a `User` with every other field +defaulted. It is now an `OAuthAuthorizingUser`, which carries exactly the two fields the server +sends, matching what the other Supabase client libraries expose. + +```dart +// Before +final User user = details.user; + +// After +final OAuthAuthorizingUser user = details.user; +``` + +`id` and `email` keep their names, so code that only reads those needs no change. + ### `order()` now sorts ascending by default `PostgrestTransformBuilder.order()` and `SupabaseStreamBuilder.order()` defaulted `ascending` to diff --git a/examples/storage_transforms/lib/models.dart b/examples/storage_transforms/lib/models.dart index 56f6dc453..861b2df47 100644 --- a/examples/storage_transforms/lib/models.dart +++ b/examples/storage_transforms/lib/models.dart @@ -5,12 +5,8 @@ import 'package:supabase_flutter/supabase_flutter.dart'; class StoredImage { const StoredImage({required this.path, this.createdAt}); - factory StoredImage.fromFileObject(FileObject file) => StoredImage( - path: file.name, - createdAt: file.createdAt == null - ? null - : DateTime.tryParse(file.createdAt!), - ); + factory StoredImage.fromFileObject(FileObject file) => + StoredImage(path: file.name, createdAt: file.createdAt); /// Path within the bucket, which is also the object's file name here. final String path; diff --git a/packages/gotrue/lib/src/gotrue_client.dart b/packages/gotrue/lib/src/gotrue_client.dart index 72218446a..08b0b74d4 100644 --- a/packages/gotrue/lib/src/gotrue_client.dart +++ b/packages/gotrue/lib/src/gotrue_client.dart @@ -1391,9 +1391,7 @@ class GoTrueClient { } final expiresInTicks = - (DateTime.fromMillisecondsSinceEpoch( - expiresAt * 1000, - ).difference(now).inMilliseconds / + (expiresAt.difference(now).inMilliseconds / Constants.autoRefreshTickDuration.inMilliseconds) .floor(); diff --git a/packages/gotrue/lib/src/gotrue_oauth_api.dart b/packages/gotrue/lib/src/gotrue_oauth_api.dart index 6cb279140..3c9af085c 100644 --- a/packages/gotrue/lib/src/gotrue_oauth_api.dart +++ b/packages/gotrue/lib/src/gotrue_oauth_api.dart @@ -34,6 +34,34 @@ class OAuthAuthorizedClient { } } +/// The signed-in user a pending OAuth authorization request belongs to. +/// +/// The OAuth 2.1 server only returns the identity needed to render a consent +/// screen, so this is a smaller shape than [User]. +/// +/// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. +class OAuthAuthorizingUser { + /// Unique identifier of the user. + final String id; + + /// Email address of the user. + final String email; + + const OAuthAuthorizingUser({required this.id, required this.email}); + + factory OAuthAuthorizingUser.fromJson(Map json) { + final id = json['id']; + final email = json['email']; + if (id is! String || email is! String) { + throw FormatException( + 'Expected the user id and email to be strings, got ' + '${id.runtimeType} and ${email.runtimeType}', + ); + } + return OAuthAuthorizingUser(id: id, email: email); + } +} + /// An OAuth grant representing a user's authorization of an OAuth client. /// /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. @@ -57,7 +85,7 @@ class OAuthGrant { return OAuthGrant( client: OAuthAuthorizedClient.fromJson(json['client']), scopes: (json['scopes'] as List?)?.cast() ?? const [], - grantedAt: DateTime.parse(json['granted_at'] as String), + grantedAt: parseIso8601(json, 'granted_at'), ); } } @@ -99,8 +127,8 @@ class OAuthAuthorizationDetailsResponse extends OAuthAuthorizationResponse { /// The OAuth client requesting authorization. final OAuthAuthorizedClient client; - /// The OAuth User requesting authorization. - final User user; + /// The user the authorization request belongs to. + final OAuthAuthorizingUser user; /// The scopes requested by the OAuth client, if any. final String? scope; @@ -119,19 +147,18 @@ class OAuthAuthorizationDetailsResponse extends OAuthAuthorizationResponse { factory OAuthAuthorizationDetailsResponse.fromJson( Map json, ) { - final user = json['user'] == null ? null : User.fromJson(json['user']); - - if (user == null) { + final user = json['user']; + if (user is! Map) { throw FormatException( - 'The provided JSON should contain a parseable user object', - json.toString(), + 'Expected the authorization details to contain a user object, got ' + '${user.runtimeType}', ); } return OAuthAuthorizationDetailsResponse( authorizationId: json['authorization_id'] as String, client: OAuthAuthorizedClient.fromJson(json['client']), - user: user, + user: OAuthAuthorizingUser.fromJson(user), scope: json['scope'] as String?, redirectUri: json['redirect_uri'] as String, ); diff --git a/packages/gotrue/lib/src/types/custom_oauth_provider.dart b/packages/gotrue/lib/src/types/custom_oauth_provider.dart index 245be1827..cca82525f 100644 --- a/packages/gotrue/lib/src/types/custom_oauth_provider.dart +++ b/packages/gotrue/lib/src/types/custom_oauth_provider.dart @@ -2,6 +2,8 @@ // prefixed with `custom:`). Distinct from the OAuth 2.1 server client types // in types.dart. +import 'package:supabase_common/supabase_common.dart'; + /// Type of a custom OAuth/OIDC provider managed through the admin API. enum CustomProviderType { oauth2, @@ -208,8 +210,8 @@ class CustomOAuthProvider { : OIDCDiscoveryDocument.fromJson( discoveryDocument as Map, ), - createdAt: DateTime.parse(json['created_at'] as String), - updatedAt: DateTime.parse(json['updated_at'] as String), + createdAt: parseIso8601(json, 'created_at'), + updatedAt: parseIso8601(json, 'updated_at'), ); } } diff --git a/packages/gotrue/lib/src/types/mfa.dart b/packages/gotrue/lib/src/types/mfa.dart index f14893ff0..36579af5e 100644 --- a/packages/gotrue/lib/src/types/mfa.dart +++ b/packages/gotrue/lib/src/types/mfa.dart @@ -1,4 +1,5 @@ import 'package:gotrue/gotrue.dart'; +import 'package:supabase_common/supabase_common.dart'; class AuthMFAEnrollResponse { /// ID of the factor that was just enrolled (in an unverified state). @@ -107,17 +108,9 @@ class AuthMFAChallengeResponse { const AuthMFAChallengeResponse({required this.id, required this.expiresAt}); factory AuthMFAChallengeResponse.fromJson(Map json) { - final expiresAtValue = json['expires_at']; - if (expiresAtValue is! num) { - throw FormatException( - 'Expected expires_at to be a number, got ${expiresAtValue.runtimeType}', - json.toString(), - ); - } - final expiresAt = expiresAtValue.toInt(); return AuthMFAChallengeResponse( id: json['id'] as String, - expiresAt: DateTime.fromMillisecondsSinceEpoch(expiresAt * 1000), + expiresAt: parseUnixSeconds(json, 'expires_at'), ); } } @@ -283,24 +276,6 @@ class Factor { }); factory Factor.fromJson(Map json) { - DateTime parseDateTime(String key) { - final value = json[key]; - if (value is! String) { - throw FormatException( - 'Expected $key to be a string, got ${value.runtimeType}', - json.toString(), - ); - } - try { - return DateTime.parse(value); - } on FormatException { - throw FormatException( - 'Invalid date format for $key: $value', - json.toString(), - ); - } - } - return Factor( id: json['id'] as String, friendlyName: json['friendly_name'] as String?, @@ -312,8 +287,8 @@ class Factor { (e) => e.name == json['status'], orElse: () => FactorStatus.unknown, ), - createdAt: parseDateTime('created_at'), - updatedAt: parseDateTime('updated_at'), + createdAt: parseIso8601(json, 'created_at'), + updatedAt: parseIso8601(json, 'updated_at'), ); } @@ -431,20 +406,12 @@ class AMREntry { const AMREntry({required this.method, required this.timestamp}); factory AMREntry.fromJson(Map json) { - final timestampValue = json['timestamp']; - if (timestampValue is! num) { - throw FormatException( - 'Expected timestamp to be a number, got ${timestampValue.runtimeType}', - json.toString(), - ); - } - final timestamp = timestampValue.toInt(); return AMREntry( method: AMRMethod.values.firstWhere( (e) => e.code == json['method'], orElse: () => AMRMethod.unknown, ), - timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), + timestamp: parseUnixSeconds(json, 'timestamp'), ); } } diff --git a/packages/gotrue/lib/src/types/passkey.dart b/packages/gotrue/lib/src/types/passkey.dart index 340bbe431..428ac96fc 100644 --- a/packages/gotrue/lib/src/types/passkey.dart +++ b/packages/gotrue/lib/src/types/passkey.dart @@ -1,4 +1,5 @@ import 'package:meta/meta.dart'; +import 'package:supabase_common/supabase_common.dart'; /// A passkey (WebAuthn credential) registered to a user. @experimental @@ -27,19 +28,11 @@ class Passkey { }); factory Passkey.fromJson(Map json) { - final createdAt = json['created_at']; - if (createdAt is! String) { - throw FormatException( - 'Expected created_at to be a string, got ${createdAt.runtimeType}', - json.toString(), - ); - } - final lastUsedAt = json['last_used_at'] as String?; return Passkey( id: json['id'] as String, friendlyName: json['friendly_name'] as String?, - createdAt: DateTime.parse(createdAt), - lastUsedAt: lastUsedAt != null ? DateTime.parse(lastUsedAt) : null, + createdAt: parseIso8601(json, 'created_at'), + lastUsedAt: tryParseIso8601(json, 'last_used_at'), ); } @@ -102,7 +95,7 @@ class PasskeyRegistrationOptionsResponse { return PasskeyRegistrationOptionsResponse( challengeId: json['challenge_id'] as String, options: Map.from(json['options'] as Map), - expiresAt: _parseExpiresAt(json), + expiresAt: parseUnixSeconds(json, 'expires_at'), ); } } @@ -137,18 +130,7 @@ class PasskeyAuthenticationOptionsResponse { return PasskeyAuthenticationOptionsResponse( challengeId: json['challenge_id'] as String, options: Map.from(json['options'] as Map), - expiresAt: _parseExpiresAt(json), + expiresAt: parseUnixSeconds(json, 'expires_at'), ); } } - -DateTime _parseExpiresAt(Map json) { - final expiresAtValue = json['expires_at']; - if (expiresAtValue is! num) { - throw FormatException( - 'Expected expires_at to be a number, got ${expiresAtValue.runtimeType}', - json.toString(), - ); - } - return DateTime.fromMillisecondsSinceEpoch(expiresAtValue.toInt() * 1000); -} diff --git a/packages/gotrue/lib/src/types/session.dart b/packages/gotrue/lib/src/types/session.dart index 214479227..414776012 100644 --- a/packages/gotrue/lib/src/types/session.dart +++ b/packages/gotrue/lib/src/types/session.dart @@ -2,6 +2,7 @@ import 'package:gotrue/src/constants.dart'; import 'package:gotrue/src/helper.dart'; import 'package:gotrue/src/types/user.dart'; import 'package:meta/meta.dart'; +import 'package:supabase_common/supabase_common.dart'; class Session { final String? providerToken; @@ -56,10 +57,13 @@ class Session { } Map toJson() { + final expiresAt = this.expiresAt; return { 'access_token': accessToken, 'expires_in': expiresIn, - 'expires_at': expiresAt, + 'expires_at': expiresAt == null + ? null + : unixSecondsFromDateTime(expiresAt), 'refresh_token': refreshToken, 'token_type': tokenType, 'provider_token': providerToken, @@ -68,19 +72,19 @@ class Session { }; } - /// The Unix timestamp, in **seconds**, of when the token will expire. + /// The point in time, in UTC, when [accessToken] expires. /// /// Derived from the `exp` claim of [accessToken], not read from the login - /// response's JSON body. - /// - /// To convert this to a [DateTime], multiply by 1000 since - /// [DateTime.fromMillisecondsSinceEpoch] expects milliseconds: - /// `DateTime.fromMillisecondsSinceEpoch(expiresAt * 1000)`. - late int? expiresAt = _expiresAt; + /// response's JSON body. `null` when [accessToken] carries no expiry or + /// cannot be decoded. + late DateTime? expiresAt = _expiresAt; - int? get _expiresAt { + DateTime? get _expiresAt { try { - return decodeJwtPayload(accessToken).exp; + final expiresAtSeconds = decodeJwtPayload(accessToken).exp; + return expiresAtSeconds == null + ? null + : dateTimeFromUnixSeconds(expiresAtSeconds); } catch (_) { return null; } @@ -91,22 +95,18 @@ class Session { /// /// The 30 second buffer is to account for latency issues. bool get isExpired { + final expiresAt = this.expiresAt; if (expiresAt == null) return false; - return DateTime.now() - .add(Constants.expiryMargin) - .isAfter( - DateTime.fromMillisecondsSinceEpoch(expiresAt! * 1000), - ); + return DateTime.now().add(Constants.expiryMargin).isAfter(expiresAt); } /// Returns `true` if the token is expired right now, without applying the /// [Constants.expiryMargin] buffer used by [isExpired]. @internal bool get isExpiredWithoutMargin { + final expiresAt = this.expiresAt; if (expiresAt == null) return false; - return DateTime.now().isAfter( - DateTime.fromMillisecondsSinceEpoch(expiresAt! * 1000), - ); + return DateTime.now().isAfter(expiresAt); } Session copyWith({ diff --git a/packages/gotrue/lib/src/types/types.dart b/packages/gotrue/lib/src/types/types.dart index 1bc8ef460..9deb7b95b 100644 --- a/packages/gotrue/lib/src/types/types.dart +++ b/packages/gotrue/lib/src/types/types.dart @@ -165,10 +165,10 @@ class OAuthClient { final String? scope; /// Timestamp when the client was created - final String createdAt; + final DateTime createdAt; /// Timestamp when the client was last updated - final String updatedAt; + final DateTime updatedAt; const OAuthClient({ required this.clientId, @@ -213,8 +213,8 @@ class OAuthClient { ) .toList(), scope: json['scope'] as String?, - createdAt: json['created_at'] as String, - updatedAt: json['updated_at'] as String, + createdAt: parseIso8601(json, 'created_at'), + updatedAt: parseIso8601(json, 'updated_at'), ); } } diff --git a/packages/gotrue/lib/src/types/user.dart b/packages/gotrue/lib/src/types/user.dart index e3d6a3afa..6f6f2eaeb 100644 --- a/packages/gotrue/lib/src/types/user.dart +++ b/packages/gotrue/lib/src/types/user.dart @@ -1,25 +1,26 @@ import 'package:collection/collection.dart'; import 'package:gotrue/src/types/mfa.dart'; +import 'package:supabase_common/supabase_common.dart'; class User { final String id; final Map appMetadata; final Map? userMetadata; final String aud; - final String? confirmationSentAt; - final String? recoverySentAt; - final String? emailChangeSentAt; + final DateTime? confirmationSentAt; + final DateTime? recoverySentAt; + final DateTime? emailChangeSentAt; final String? newEmail; - final String? invitedAt; + final DateTime? invitedAt; final String? actionLink; final String? email; final String? phone; - final String createdAt; - final String? emailConfirmedAt; - final String? phoneConfirmedAt; - final String? lastSignInAt; + final DateTime createdAt; + final DateTime? emailConfirmedAt; + final DateTime? phoneConfirmedAt; + final DateTime? lastSignInAt; final String? role; - final String? updatedAt; + final DateTime? updatedAt; final List? identities; final List? factors; final bool isAnonymous; @@ -60,20 +61,20 @@ class User { appMetadata: json['app_metadata'] as Map? ?? {}, userMetadata: json['user_metadata'] as Map?, aud: json['aud'] ?? '', - confirmationSentAt: json['confirmation_sent_at'], - recoverySentAt: json['recovery_sent_at'], - emailChangeSentAt: json['email_change_sent_at'], + confirmationSentAt: tryParseIso8601(json, 'confirmation_sent_at'), + recoverySentAt: tryParseIso8601(json, 'recovery_sent_at'), + emailChangeSentAt: tryParseIso8601(json, 'email_change_sent_at'), newEmail: json['new_email'], - invitedAt: json['invited_at'], + invitedAt: tryParseIso8601(json, 'invited_at'), actionLink: json['action_link'], email: json['email'], phone: json['phone'], - createdAt: json['created_at'] ?? '', - emailConfirmedAt: json['email_confirmed_at'], - phoneConfirmedAt: json['phone_confirmed_at'], - lastSignInAt: json['last_sign_in_at'], + createdAt: parseIso8601(json, 'created_at'), + emailConfirmedAt: tryParseIso8601(json, 'email_confirmed_at'), + phoneConfirmedAt: tryParseIso8601(json, 'phone_confirmed_at'), + lastSignInAt: tryParseIso8601(json, 'last_sign_in_at'), role: json['role'], - updatedAt: json['updated_at'], + updatedAt: tryParseIso8601(json, 'updated_at'), identities: json['identities'] != null ? List.from( json['identities']?.map((x) => UserIdentity.fromMap(x)), @@ -92,20 +93,20 @@ class User { 'app_metadata': appMetadata, 'user_metadata': userMetadata, 'aud': aud, - 'confirmation_sent_at': confirmationSentAt, - 'recovery_sent_at': recoverySentAt, - 'email_change_sent_at': emailChangeSentAt, + 'confirmation_sent_at': confirmationSentAt?.toIso8601String(), + 'recovery_sent_at': recoverySentAt?.toIso8601String(), + 'email_change_sent_at': emailChangeSentAt?.toIso8601String(), 'new_email': newEmail, - 'invited_at': invitedAt, + 'invited_at': invitedAt?.toIso8601String(), 'action_link': actionLink, 'email': email, 'phone': phone, - 'created_at': createdAt, - 'email_confirmed_at': emailConfirmedAt, - 'phone_confirmed_at': phoneConfirmedAt, - 'last_sign_in_at': lastSignInAt, + 'created_at': createdAt.toIso8601String(), + 'email_confirmed_at': emailConfirmedAt?.toIso8601String(), + 'phone_confirmed_at': phoneConfirmedAt?.toIso8601String(), + 'last_sign_in_at': lastSignInAt?.toIso8601String(), 'role': role, - 'updated_at': updatedAt, + 'updated_at': updatedAt?.toIso8601String(), 'identities': identities?.map((identity) => identity.toJson()).toList(), 'factors': factors?.map((factor) => factor.toJson()).toList(), 'is_anonymous': isAnonymous, @@ -156,9 +157,11 @@ class User { @override int get hashCode { + final collectionHash = const DeepCollectionEquality().hash; + return id.hashCode ^ - appMetadata.hashCode ^ - userMetadata.hashCode ^ + collectionHash(appMetadata) ^ + collectionHash(userMetadata) ^ aud.hashCode ^ confirmationSentAt.hashCode ^ recoverySentAt.hashCode ^ @@ -174,8 +177,8 @@ class User { lastSignInAt.hashCode ^ role.hashCode ^ updatedAt.hashCode ^ - identities.hashCode ^ - factors.hashCode ^ + collectionHash(identities) ^ + collectionHash(factors) ^ isAnonymous.hashCode; } } @@ -186,9 +189,9 @@ class UserIdentity { final Map? identityData; final String identityId; final String provider; - final String? createdAt; - final String? lastSignInAt; - final String? updatedAt; + final DateTime? createdAt; + final DateTime? lastSignInAt; + final DateTime? updatedAt; const UserIdentity({ required this.id, @@ -207,9 +210,9 @@ class UserIdentity { Map? identityData, String? identityId, String? provider, - String? createdAt, - String? lastSignInAt, - String? updatedAt, + DateTime? createdAt, + DateTime? lastSignInAt, + DateTime? updatedAt, }) { return UserIdentity( id: id ?? this.id, @@ -230,9 +233,9 @@ class UserIdentity { identityData: (map['identity_data'] as Map?)?.cast(), identityId: (map['identity_id'] ?? '') as String, provider: map['provider'] as String, - createdAt: map['created_at'] as String?, - lastSignInAt: map['last_sign_in_at'] as String?, - updatedAt: map['updated_at'] as String?, + createdAt: tryParseIso8601(map, 'created_at'), + lastSignInAt: tryParseIso8601(map, 'last_sign_in_at'), + updatedAt: tryParseIso8601(map, 'updated_at'), ); } @@ -243,9 +246,9 @@ class UserIdentity { 'identity_data': identityData, 'identity_id': identityId, 'provider': provider, - 'created_at': createdAt, - 'last_sign_in_at': lastSignInAt, - 'updated_at': updatedAt, + 'created_at': createdAt?.toIso8601String(), + 'last_sign_in_at': lastSignInAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), }; } @@ -277,7 +280,7 @@ class UserIdentity { int get hashCode { return id.hashCode ^ userId.hashCode ^ - identityData.hashCode ^ + const DeepCollectionEquality().hash(identityData) ^ identityId.hashCode ^ provider.hashCode ^ createdAt.hashCode ^ diff --git a/packages/gotrue/test/client_test.dart b/packages/gotrue/test/client_test.dart index dd555bb28..5daac4a87 100644 --- a/packages/gotrue/test/client_test.dart +++ b/packages/gotrue/test/client_test.dart @@ -245,7 +245,10 @@ void main() { expect(data?.user.id, isA()); final payload = decodeJwt(data!.accessToken).payload; - expect(payload.exp, data.expiresAt); + expect( + data.expiresAt, + DateTime.fromMillisecondsSinceEpoch(payload.exp! * 1000, isUtc: true), + ); }); test('Get user', () async { @@ -269,7 +272,10 @@ void main() { expect(data?.user.id, isA()); final payload = decodeJwt(data!.accessToken).payload; - expect(payload.exp, data.expiresAt); + expect( + data.expiresAt, + DateTime.fromMillisecondsSinceEpoch(payload.exp! * 1000, isUtc: true), + ); }); test('Set session', () async { diff --git a/packages/gotrue/test/passkey_test.dart b/packages/gotrue/test/passkey_test.dart index 420d7f681..21af80765 100644 --- a/packages/gotrue/test/passkey_test.dart +++ b/packages/gotrue/test/passkey_test.dart @@ -57,7 +57,7 @@ void main() { expect(response.options['challenge'], isNotEmpty); expect( response.expiresAt, - DateTime.fromMillisecondsSinceEpoch(1735689900 * 1000), + DateTime.fromMillisecondsSinceEpoch(1735689900 * 1000, isUtc: true), ); }); @@ -114,7 +114,7 @@ void main() { expect(response.options['user']['name'], 'user@example.com'); expect( response.expiresAt, - DateTime.fromMillisecondsSinceEpoch(1735689900 * 1000), + DateTime.fromMillisecondsSinceEpoch(1735689900 * 1000, isUtc: true), ); }); diff --git a/packages/gotrue/test/src/gotrue_oauth_api_test.dart b/packages/gotrue/test/src/gotrue_oauth_api_test.dart index 8ab8135fc..f0dd16b9a 100644 --- a/packages/gotrue/test/src/gotrue_oauth_api_test.dart +++ b/packages/gotrue/test/src/gotrue_oauth_api_test.dart @@ -44,11 +44,66 @@ void main() { equals('7263e727-435b-4d38-a5ff-a14c954b8680'), ); expect(actual.client.clientName, equals('OAuth test client')); + expect(actual.user, isA()); expect(actual.user.id, equals('1bee2038-51fe-4f93-8fbb-442df18657ff')); expect(actual.user.email, equals('translator.user@mail.com')); }); - test('throws ArgumentError when user information is missing', () { + test('throws FormatException when the user is not an object', () { + final json = { + 'authorization_id': '6abuj667j4nmdotzu3w2ro5r33xezvae', + 'redirect_uri': 'http://localhost:50200/onboarding/auth/consent', + 'client': { + 'id': '7263e727-435b-4d38-a5ff-a14c954b8680', + 'name': 'OAuth test client', + }, + 'user': 'translator.user@mail.com', + 'scope': 'email', + }; + + expect( + () => OAuthAuthorizationDetailsResponse.fromJson(json), + throwsFormatException, + ); + }); + + test('throws FormatException when the user id is not a string', () { + final json = { + 'authorization_id': '6abuj667j4nmdotzu3w2ro5r33xezvae', + 'redirect_uri': 'http://localhost:50200/onboarding/auth/consent', + 'client': { + 'id': '7263e727-435b-4d38-a5ff-a14c954b8680', + 'name': 'OAuth test client', + }, + 'user': {'id': 42, 'email': 'translator.user@mail.com'}, + 'scope': 'email', + }; + + expect( + () => OAuthAuthorizationDetailsResponse.fromJson(json), + throwsFormatException, + ); + }); + + test('throws FormatException when the user email is missing', () { + final json = { + 'authorization_id': '6abuj667j4nmdotzu3w2ro5r33xezvae', + 'redirect_uri': 'http://localhost:50200/onboarding/auth/consent', + 'client': { + 'id': '7263e727-435b-4d38-a5ff-a14c954b8680', + 'name': 'OAuth test client', + }, + 'user': {'id': '1bee2038-51fe-4f93-8fbb-442df18657ff'}, + 'scope': 'email', + }; + + expect( + () => OAuthAuthorizationDetailsResponse.fromJson(json), + throwsFormatException, + ); + }); + + test('throws FormatException when user information is missing', () { final json = { 'authorization_id': '6abuj667j4nmdotzu3w2ro5r33xezvae', 'redirect_uri': 'http://localhost:50200/onboarding/auth/consent', diff --git a/packages/gotrue/test/src/set_session_test.dart b/packages/gotrue/test/src/set_session_test.dart index c4ffc8441..7457f8b28 100644 --- a/packages/gotrue/test/src/set_session_test.dart +++ b/packages/gotrue/test/src/set_session_test.dart @@ -204,7 +204,10 @@ void main() { ); // expiresAt is re-derived from the JWT's own exp, not from expiresIn. - expect(response.session?.expiresAt, equals(exp)); + expect( + response.session?.expiresAt, + equals(DateTime.fromMillisecondsSinceEpoch(exp * 1000, isUtc: true)), + ); }); test( diff --git a/packages/gotrue/test/src/types/mfa_test.dart b/packages/gotrue/test/src/types/mfa_test.dart index 4118036f3..f0f658701 100644 --- a/packages/gotrue/test/src/types/mfa_test.dart +++ b/packages/gotrue/test/src/types/mfa_test.dart @@ -38,7 +38,7 @@ void main() { expect(entry.method, AMRMethod.passkey); expect( entry.timestamp, - DateTime.fromMillisecondsSinceEpoch(1735689600 * 1000), + DateTime.fromMillisecondsSinceEpoch(1735689600 * 1000, isUtc: true), ); }); diff --git a/packages/gotrue/test/src/types/passkey_test.dart b/packages/gotrue/test/src/types/passkey_test.dart index 1b05c6b3a..45c353830 100644 --- a/packages/gotrue/test/src/types/passkey_test.dart +++ b/packages/gotrue/test/src/types/passkey_test.dart @@ -75,7 +75,7 @@ void main() { expect(response.options['rp'], {'id': 'example.com'}); expect( response.expiresAt, - DateTime.fromMillisecondsSinceEpoch(1735689900 * 1000), + DateTime.fromMillisecondsSinceEpoch(1735689900 * 1000, isUtc: true), ); }); @@ -106,7 +106,7 @@ void main() { expect(response.options['rpId'], 'example.com'); expect( response.expiresAt, - DateTime.fromMillisecondsSinceEpoch(1735689900 * 1000), + DateTime.fromMillisecondsSinceEpoch(1735689900 * 1000, isUtc: true), ); }); diff --git a/packages/gotrue/test/src/types/session_test.dart b/packages/gotrue/test/src/types/session_test.dart index d18d0eb53..f0fedd04d 100644 --- a/packages/gotrue/test/src/types/session_test.dart +++ b/packages/gotrue/test/src/types/session_test.dart @@ -10,12 +10,12 @@ void main() { late User mockUser; setUp(() { - mockUser = const User( + mockUser = User( id: '123', appMetadata: {}, userMetadata: {}, aud: 'authenticated', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), ); }); @@ -144,7 +144,22 @@ void main() { expect(json['expires_at'], isNotNull); }); - test('includes computed expiresAt field', () { + test('serializes expiresAt as Unix seconds', () { + final expiresAtSeconds = 1700000000; + final header = base64Encode(utf8.encode('{"alg":"HS256","typ":"JWT"}')); + final payload = base64Encode( + utf8.encode('{"exp":$expiresAtSeconds}'), + ); + final session = Session( + accessToken: '$header.$payload.signature', + tokenType: 'bearer', + user: mockUser, + ); + + expect(session.toJson()['expires_at'], equals(expiresAtSeconds)); + }); + + test('serializes expires_at as null when the JWT has no expiry', () { final session = Session( accessToken: 'test-access-token', tokenType: 'bearer', @@ -154,7 +169,7 @@ void main() { final json = session.toJson(); expect(json, contains('expires_at')); - expect(json['expires_at'], equals(session.expiresAt)); + expect(json['expires_at'], isNull); }); }); @@ -169,7 +184,7 @@ void main() { expect(session.expiresAt, isNull); }); - test('returns exp claim from valid JWT', () { + test('returns the exp claim of the JWT as a UTC DateTime', () { final now = DateTime.now(); final exp = (now.millisecondsSinceEpoch / 1000).floor() + 3600; final header = base64Encode(utf8.encode('{"alg":"HS256","typ":"JWT"}')); @@ -182,7 +197,11 @@ void main() { user: mockUser, ); - expect(session.expiresAt, equals(exp)); + expect( + session.expiresAt, + equals(DateTime.fromMillisecondsSinceEpoch(exp * 1000, isUtc: true)), + ); + expect(session.expiresAt!.isUtc, isTrue); }); test('handles malformed JWT gracefully', () { @@ -294,12 +313,12 @@ void main() { user: mockUser, ); - final newUser = const User( + final newUser = User( id: '456', appMetadata: {}, userMetadata: {}, aud: 'authenticated', - createdAt: '2023-01-02T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 2), ); final copy = original.copyWith(user: newUser); @@ -319,12 +338,12 @@ void main() { user: mockUser, ); - final newUser = const User( + final newUser = User( id: '456', appMetadata: {}, userMetadata: {}, aud: 'authenticated', - createdAt: '2023-01-02T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 2), ); final copy = original.copyWith( @@ -441,20 +460,20 @@ void main() { }); test('returns false for sessions with different users', () { - final user1 = const User( + final user1 = User( id: '123', appMetadata: {}, userMetadata: {}, aud: 'authenticated', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), ); - final user2 = const User( + final user2 = User( id: '456', appMetadata: {}, userMetadata: {}, aud: 'authenticated', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), ); final session1 = Session( diff --git a/packages/gotrue/test/src/types/user_test.dart b/packages/gotrue/test/src/types/user_test.dart index 3261e31a1..7f213b2c6 100644 --- a/packages/gotrue/test/src/types/user_test.dart +++ b/packages/gotrue/test/src/types/user_test.dart @@ -47,7 +47,7 @@ void main() { expect(user.appMetadata, equals({})); expect(user.userMetadata, equals({})); expect(user.aud, equals('authenticated')); - expect(user.createdAt, equals('2023-01-01T00:00:00Z')); + expect(user.createdAt, equals(DateTime.utc(2023, 1, 1))); expect(user.isAnonymous, isFalse); }); @@ -87,20 +87,20 @@ void main() { equals({'name': 'John Doe'}), ); expect(user.aud, equals('authenticated')); - expect(user.confirmationSentAt, equals('2023-01-01T00:00:00Z')); - expect(user.recoverySentAt, equals('2023-01-01T01:00:00Z')); - expect(user.emailChangeSentAt, equals('2023-01-01T02:00:00Z')); + expect(user.confirmationSentAt, equals(DateTime.utc(2023, 1, 1))); + expect(user.recoverySentAt, equals(DateTime.utc(2023, 1, 1, 1))); + expect(user.emailChangeSentAt, equals(DateTime.utc(2023, 1, 1, 2))); expect(user.newEmail, equals('new@example.com')); - expect(user.invitedAt, equals('2023-01-01T03:00:00Z')); + expect(user.invitedAt, equals(DateTime.utc(2023, 1, 1, 3))); expect(user.actionLink, equals('https://example.com/action')); expect(user.email, equals('test@example.com')); expect(user.phone, equals('+1234567890')); - expect(user.createdAt, equals('2023-01-01T00:00:00Z')); - expect(user.emailConfirmedAt, equals('2023-01-01T05:00:00Z')); - expect(user.phoneConfirmedAt, equals('2023-01-01T06:00:00Z')); - expect(user.lastSignInAt, equals('2023-01-01T07:00:00Z')); + expect(user.createdAt, equals(DateTime.utc(2023, 1, 1))); + expect(user.emailConfirmedAt, equals(DateTime.utc(2023, 1, 1, 5))); + expect(user.phoneConfirmedAt, equals(DateTime.utc(2023, 1, 1, 6))); + expect(user.lastSignInAt, equals(DateTime.utc(2023, 1, 1, 7))); expect(user.role, equals('authenticated')); - expect(user.updatedAt, equals('2023-01-01T08:00:00Z')); + expect(user.updatedAt, equals(DateTime.utc(2023, 1, 1, 8))); expect(user.isAnonymous, isTrue); }); @@ -148,7 +148,7 @@ void main() { expect(user!.appMetadata, equals({})); }); - test('handles empty string defaults for id, aud, and createdAt', () { + test('returns null when id is null, before parsing any timestamp', () { final json = { 'id': null, 'app_metadata': {}, @@ -162,6 +162,44 @@ void main() { expect(user, isNull); }); + test('throws when created_at is missing', () { + final json = { + 'id': '123', + 'app_metadata': {}, + 'user_metadata': {}, + 'aud': 'authenticated', + }; + + expect(() => User.fromJson(json), throwsFormatException); + }); + + test('throws when created_at is not a valid timestamp', () { + final json = { + 'id': '123', + 'app_metadata': {}, + 'user_metadata': {}, + 'aud': 'authenticated', + 'created_at': 'not a timestamp', + }; + + expect(() => User.fromJson(json), throwsFormatException); + }); + + test('normalizes timestamps with an offset to UTC', () { + final json = { + 'id': '123', + 'app_metadata': {}, + 'user_metadata': {}, + 'aud': 'authenticated', + 'created_at': '2023-01-01T02:00:00+02:00', + }; + + final user = User.fromJson(json); + + expect(user!.createdAt, equals(DateTime.utc(2023, 1, 1))); + expect(user.createdAt.isUtc, isTrue); + }); + test('creates user with identities', () { final json = { 'id': '123', @@ -255,25 +293,25 @@ void main() { group('toJson', () { test('serializes user correctly', () { - const user = User( + final user = User( id: '123', appMetadata: {'provider': 'email'}, userMetadata: {'name': 'John Doe'}, aud: 'authenticated', - confirmationSentAt: '2023-01-01T00:00:00Z', - recoverySentAt: '2023-01-01T01:00:00Z', - emailChangeSentAt: '2023-01-01T02:00:00Z', + confirmationSentAt: DateTime.utc(2023, 1, 1), + recoverySentAt: DateTime.utc(2023, 1, 1, 1), + emailChangeSentAt: DateTime.utc(2023, 1, 1, 2), newEmail: 'new@example.com', - invitedAt: '2023-01-01T03:00:00Z', + invitedAt: DateTime.utc(2023, 1, 1, 3), actionLink: 'https://example.com/action', email: 'test@example.com', phone: '+1234567890', - createdAt: '2023-01-01T00:00:00Z', - emailConfirmedAt: '2023-01-01T05:00:00Z', - phoneConfirmedAt: '2023-01-01T06:00:00Z', - lastSignInAt: '2023-01-01T07:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), + emailConfirmedAt: DateTime.utc(2023, 1, 1, 5), + phoneConfirmedAt: DateTime.utc(2023, 1, 1, 6), + lastSignInAt: DateTime.utc(2023, 1, 1, 7), role: 'authenticated', - updatedAt: '2023-01-01T08:00:00Z', + updatedAt: DateTime.utc(2023, 1, 1, 8), isAnonymous: true, ); @@ -283,40 +321,46 @@ void main() { expect(json['app_metadata'], equals({'provider': 'email'})); expect(json['user_metadata'], equals({'name': 'John Doe'})); expect(json['aud'], equals('authenticated')); - expect(json['confirmation_sent_at'], equals('2023-01-01T00:00:00Z')); - expect(json['recovery_sent_at'], equals('2023-01-01T01:00:00Z')); - expect(json['email_change_sent_at'], equals('2023-01-01T02:00:00Z')); + expect( + json['confirmation_sent_at'], + equals('2023-01-01T00:00:00.000Z'), + ); + expect(json['recovery_sent_at'], equals('2023-01-01T01:00:00.000Z')); + expect( + json['email_change_sent_at'], + equals('2023-01-01T02:00:00.000Z'), + ); expect(json['new_email'], equals('new@example.com')); - expect(json['invited_at'], equals('2023-01-01T03:00:00Z')); + expect(json['invited_at'], equals('2023-01-01T03:00:00.000Z')); expect(json['action_link'], equals('https://example.com/action')); expect(json['email'], equals('test@example.com')); expect(json['phone'], equals('+1234567890')); - expect(json['created_at'], equals('2023-01-01T00:00:00Z')); - expect(json['email_confirmed_at'], equals('2023-01-01T05:00:00Z')); - expect(json['phone_confirmed_at'], equals('2023-01-01T06:00:00Z')); - expect(json['last_sign_in_at'], equals('2023-01-01T07:00:00Z')); + expect(json['created_at'], equals('2023-01-01T00:00:00.000Z')); + expect(json['email_confirmed_at'], equals('2023-01-01T05:00:00.000Z')); + expect(json['phone_confirmed_at'], equals('2023-01-01T06:00:00.000Z')); + expect(json['last_sign_in_at'], equals('2023-01-01T07:00:00.000Z')); expect(json['role'], equals('authenticated')); - expect(json['updated_at'], equals('2023-01-01T08:00:00Z')); + expect(json['updated_at'], equals('2023-01-01T08:00:00.000Z')); expect(json['is_anonymous'], equals(true)); }); test('serializes identities correctly', () { - const identity = UserIdentity( + final identity = UserIdentity( id: 'identity-1', userId: '123', identityData: {'email': 'test@example.com'}, identityId: 'identity-1', provider: 'email', - createdAt: '2023-01-01T00:00:00Z', - lastSignInAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), + lastSignInAt: DateTime.utc(2023, 1, 1), ); - const user = User( + final user = User( id: '123', appMetadata: {}, userMetadata: {}, aud: 'authenticated', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), identities: [identity], ); @@ -328,12 +372,12 @@ void main() { }); test('handles null identities and factors', () { - const user = User( + final user = User( id: '123', appMetadata: {}, userMetadata: {}, aud: 'authenticated', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), identities: null, factors: null, ); @@ -347,13 +391,13 @@ void main() { group('toString', () { test('includes all user properties', () { - const user = User( + final user = User( id: '123', appMetadata: {'provider': 'email'}, userMetadata: {'name': 'John Doe'}, aud: 'authenticated', email: 'test@example.com', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), isAnonymous: true, ); @@ -368,23 +412,23 @@ void main() { group('equality and hashCode', () { test('returns true for identical users', () { - const user1 = User( + final user1 = User( id: '123', appMetadata: {'provider': 'email'}, userMetadata: {'name': 'John Doe'}, aud: 'authenticated', email: 'test@example.com', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), isAnonymous: false, ); - const user2 = User( + final user2 = User( id: '123', appMetadata: {'provider': 'email'}, userMetadata: {'name': 'John Doe'}, aud: 'authenticated', email: 'test@example.com', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), isAnonymous: false, ); @@ -393,47 +437,47 @@ void main() { }); test('returns false for users with different ids', () { - const user1 = User( + final user1 = User( id: '123', appMetadata: {}, userMetadata: {}, aud: 'authenticated', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), ); - const user2 = User( + final user2 = User( id: '456', appMetadata: {}, userMetadata: {}, aud: 'authenticated', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), ); expect(user1, isNot(equals(user2))); }); test('returns false for users with different metadata', () { - const user1 = User( + final user1 = User( id: '123', appMetadata: {'provider': 'email'}, userMetadata: {}, aud: 'authenticated', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), ); - const user2 = User( + final user2 = User( id: '123', appMetadata: {'provider': 'oauth'}, userMetadata: {}, aud: 'authenticated', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), ); expect(user1, isNot(equals(user2))); }); test('handles deep collection equality correctly', () { - const user1 = User( + final user1 = User( id: '123', appMetadata: { 'nested': {'key': 'value'}, @@ -442,10 +486,10 @@ void main() { 'list': [1, 2, 3], }, aud: 'authenticated', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), ); - const user2 = User( + final user2 = User( id: '123', appMetadata: { 'nested': {'key': 'value'}, @@ -454,7 +498,7 @@ void main() { 'list': [1, 2, 3], }, aud: 'authenticated', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), ); expect(user1, equals(user2)); @@ -463,14 +507,14 @@ void main() { group('roundtrip serialization', () { test('preserves all data through JSON roundtrip', () { - const original = User( + final original = User( id: '123', appMetadata: {'provider': 'email'}, userMetadata: {'name': 'John Doe'}, aud: 'authenticated', email: 'test@example.com', phone: '+1234567890', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), isAnonymous: true, ); @@ -481,7 +525,7 @@ void main() { }); test('preserves complex nested data', () { - const original = User( + final original = User( id: '123', appMetadata: { 'provider': 'oauth', @@ -493,7 +537,7 @@ void main() { 'preferences': ['dark_mode', 'notifications'], }, aud: 'authenticated', - createdAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), ); final json = original.toJson(); @@ -528,9 +572,9 @@ void main() { ); expect(identity.identityId, equals('identity-1')); expect(identity.provider, equals('email')); - expect(identity.createdAt, equals('2023-01-01T00:00:00Z')); - expect(identity.lastSignInAt, equals('2023-01-01T00:00:00Z')); - expect(identity.updatedAt, equals('2023-01-01T08:00:00Z')); + expect(identity.createdAt, equals(DateTime.utc(2023, 1, 1))); + expect(identity.lastSignInAt, equals(DateTime.utc(2023, 1, 1))); + expect(identity.updatedAt, equals(DateTime.utc(2023, 1, 1, 8))); }); test('handles missing identity_id with empty string default', () { @@ -583,15 +627,15 @@ void main() { group('toJson', () { test('serializes identity correctly', () { - const identity = UserIdentity( + final identity = UserIdentity( id: 'identity-1', userId: '123', identityData: {'email': 'test@example.com'}, identityId: 'identity-1', provider: 'email', - createdAt: '2023-01-01T00:00:00Z', - lastSignInAt: '2023-01-01T00:00:00Z', - updatedAt: '2023-01-01T08:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), + lastSignInAt: DateTime.utc(2023, 1, 1), + updatedAt: DateTime.utc(2023, 1, 1, 8), ); final json = identity.toJson(); @@ -604,27 +648,27 @@ void main() { ); expect(json['identity_id'], equals('identity-1')); expect(json['provider'], equals('email')); - expect(json['created_at'], equals('2023-01-01T00:00:00Z')); - expect(json['last_sign_in_at'], equals('2023-01-01T00:00:00Z')); - expect(json['updated_at'], equals('2023-01-01T08:00:00Z')); + expect(json['created_at'], equals('2023-01-01T00:00:00.000Z')); + expect(json['last_sign_in_at'], equals('2023-01-01T00:00:00.000Z')); + expect(json['updated_at'], equals('2023-01-01T08:00:00.000Z')); }); }); group('copyWith', () { test('creates copy with updated fields', () { - const original = UserIdentity( + final original = UserIdentity( id: 'identity-1', userId: '123', identityData: {'email': 'old@example.com'}, identityId: 'identity-1', provider: 'email', - createdAt: '2023-01-01T00:00:00Z', - lastSignInAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), + lastSignInAt: DateTime.utc(2023, 1, 1), ); final copy = original.copyWith( identityData: {'email': 'new@example.com'}, - lastSignInAt: '2023-01-02T00:00:00Z', + lastSignInAt: DateTime.utc(2023, 1, 2), ); expect(copy.id, equals(original.id)); @@ -636,18 +680,18 @@ void main() { expect(copy.identityId, equals(original.identityId)); expect(copy.provider, equals(original.provider)); expect(copy.createdAt, equals(original.createdAt)); - expect(copy.lastSignInAt, equals('2023-01-02T00:00:00Z')); + expect(copy.lastSignInAt, equals(DateTime.utc(2023, 1, 2))); }); test('preserves original values when no updates provided', () { - const original = UserIdentity( + final original = UserIdentity( id: 'identity-1', userId: '123', identityData: {'email': 'test@example.com'}, identityId: 'identity-1', provider: 'email', - createdAt: '2023-01-01T00:00:00Z', - lastSignInAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), + lastSignInAt: DateTime.utc(2023, 1, 1), ); final copy = original.copyWith(); @@ -665,14 +709,14 @@ void main() { group('toString', () { test('includes all identity properties', () { - const identity = UserIdentity( + final identity = UserIdentity( id: 'identity-1', userId: '123', identityData: {'email': 'test@example.com'}, identityId: 'identity-1', provider: 'email', - createdAt: '2023-01-01T00:00:00Z', - lastSignInAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), + lastSignInAt: DateTime.utc(2023, 1, 1), ); final string = identity.toString(); @@ -686,24 +730,24 @@ void main() { group('equality and hashCode', () { test('returns true for identical identities', () { - const identity1 = UserIdentity( + final identity1 = UserIdentity( id: 'identity-1', userId: '123', identityData: {'email': 'test@example.com'}, identityId: 'identity-1', provider: 'email', - createdAt: '2023-01-01T00:00:00Z', - lastSignInAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), + lastSignInAt: DateTime.utc(2023, 1, 1), ); - const identity2 = UserIdentity( + final identity2 = UserIdentity( id: 'identity-1', userId: '123', identityData: {'email': 'test@example.com'}, identityId: 'identity-1', provider: 'email', - createdAt: '2023-01-01T00:00:00Z', - lastSignInAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), + lastSignInAt: DateTime.utc(2023, 1, 1), ); expect(identity1, equals(identity2)); @@ -711,31 +755,31 @@ void main() { }); test('returns false for identities with different providers', () { - const identity1 = UserIdentity( + final identity1 = UserIdentity( id: 'identity-1', userId: '123', identityData: {'email': 'test@example.com'}, identityId: 'identity-1', provider: 'email', - createdAt: '2023-01-01T00:00:00Z', - lastSignInAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), + lastSignInAt: DateTime.utc(2023, 1, 1), ); - const identity2 = UserIdentity( + final identity2 = UserIdentity( id: 'identity-1', userId: '123', identityData: {'email': 'test@example.com'}, identityId: 'identity-1', provider: 'google', - createdAt: '2023-01-01T00:00:00Z', - lastSignInAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), + lastSignInAt: DateTime.utc(2023, 1, 1), ); expect(identity1, isNot(equals(identity2))); }); test('handles deep map equality correctly', () { - const identity1 = UserIdentity( + final identity1 = UserIdentity( id: 'identity-1', userId: '123', identityData: { @@ -743,11 +787,11 @@ void main() { }, identityId: 'identity-1', provider: 'email', - createdAt: '2023-01-01T00:00:00Z', - lastSignInAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), + lastSignInAt: DateTime.utc(2023, 1, 1), ); - const identity2 = UserIdentity( + final identity2 = UserIdentity( id: 'identity-1', userId: '123', identityData: { @@ -755,8 +799,8 @@ void main() { }, identityId: 'identity-1', provider: 'email', - createdAt: '2023-01-01T00:00:00Z', - lastSignInAt: '2023-01-01T00:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), + lastSignInAt: DateTime.utc(2023, 1, 1), ); expect(identity1, equals(identity2)); @@ -765,7 +809,7 @@ void main() { group('roundtrip serialization', () { test('preserves all data through JSON roundtrip', () { - const original = UserIdentity( + final original = UserIdentity( id: 'identity-1', userId: '123', identityData: { @@ -774,9 +818,9 @@ void main() { }, identityId: 'identity-1', provider: 'email', - createdAt: '2023-01-01T00:00:00Z', - lastSignInAt: '2023-01-01T00:00:00Z', - updatedAt: '2023-01-01T08:00:00Z', + createdAt: DateTime.utc(2023, 1, 1), + lastSignInAt: DateTime.utc(2023, 1, 1), + updatedAt: DateTime.utc(2023, 1, 1, 8), ); final json = original.toJson(); diff --git a/packages/realtime_client/lib/src/types.dart b/packages/realtime_client/lib/src/types.dart index eebc7528b..efa2be8f1 100644 --- a/packages/realtime_client/lib/src/types.dart +++ b/packages/realtime_client/lib/src/types.dart @@ -303,14 +303,14 @@ class PostgresChangePayload { /// Creates a PostgresChangePayload instance from the enriched postgres change /// payload factory PostgresChangePayload.fromPayload(Map payload) { - final commitTimestampStr = payload['commit_timestamp'] as String?; + final commitTimestampValue = payload['commit_timestamp']; DateTime commitTimestamp; try { - commitTimestamp = commitTimestampStr != null - ? DateTime.parse(commitTimestampStr) - : DateTime.fromMillisecondsSinceEpoch(0); + commitTimestamp = commitTimestampValue is String + ? DateTime.parse(commitTimestampValue).toUtc() + : DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); } on FormatException { - commitTimestamp = DateTime.fromMillisecondsSinceEpoch(0); + commitTimestamp = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); } final newData = payload['new']; diff --git a/packages/realtime_client/test/types_test.dart b/packages/realtime_client/test/types_test.dart new file mode 100644 index 000000000..92acdc715 --- /dev/null +++ b/packages/realtime_client/test/types_test.dart @@ -0,0 +1,61 @@ +import 'package:realtime_client/realtime_client.dart'; +import 'package:test/test.dart'; + +Map payloadWith(Object? commitTimestamp) => { + 'schema': 'public', + 'table': 'messages', + 'commit_timestamp': commitTimestamp, + 'eventType': 'INSERT', + 'new': {'id': 1}, + 'old': {}, + 'errors': null, +}; + +void main() { + group('PostgresChangePayload.fromPayload', () { + test('parses a commit timestamp as UTC', () { + final payload = PostgresChangePayload.fromPayload( + payloadWith('2022-09-21T04:59:30Z'), + ); + + expect(payload.commitTimestamp, DateTime.utc(2022, 9, 21, 4, 59, 30)); + expect(payload.commitTimestamp.isUtc, isTrue); + }); + + test('normalizes a commit timestamp with an offset to UTC', () { + final payload = PostgresChangePayload.fromPayload( + payloadWith('2022-09-21T06:59:30+02:00'), + ); + + expect(payload.commitTimestamp, DateTime.utc(2022, 9, 21, 4, 59, 30)); + expect(payload.commitTimestamp.isUtc, isTrue); + }); + + test('falls back to the UTC epoch when the timestamp is missing', () { + final payload = PostgresChangePayload.fromPayload(payloadWith(null)); + + expect(payload.commitTimestamp, DateTime.utc(1970)); + expect(payload.commitTimestamp.isUtc, isTrue); + }); + + test('falls back to the UTC epoch when the timestamp is malformed', () { + final payload = PostgresChangePayload.fromPayload( + payloadWith('not a timestamp'), + ); + + expect(payload.commitTimestamp, DateTime.utc(1970)); + expect(payload.commitTimestamp.isUtc, isTrue); + }); + + test('falls back to the UTC epoch when the timestamp is not a string', () { + // Casting this to String? raised a TypeError, which the FormatException + // fallback around the parse does not catch. + final payload = PostgresChangePayload.fromPayload( + payloadWith(1663764570), + ); + + expect(payload.commitTimestamp, DateTime.utc(1970)); + expect(payload.commitTimestamp.isUtc, isTrue); + }); + }); +} diff --git a/packages/storage_client/lib/src/types.dart b/packages/storage_client/lib/src/types.dart index f92dc8b61..1ddf2e368 100644 --- a/packages/storage_client/lib/src/types.dart +++ b/packages/storage_client/lib/src/types.dart @@ -5,8 +5,8 @@ class Bucket { final String id; final String name; final String owner; - final String createdAt; - final String updatedAt; + final DateTime createdAt; + final DateTime updatedAt; final bool public; final int? fileSizeLimit; final List? allowedMimeTypes; @@ -28,8 +28,8 @@ class Bucket { id: json['id'] as String, name: json['name'] as String, owner: json['owner'] as String? ?? '', - createdAt: json['created_at'] as String, - updatedAt: json['updated_at'] as String, + createdAt: parseIso8601(json, 'created_at'), + updatedAt: parseIso8601(json, 'updated_at'), public: json['public'] as bool, fileSizeLimit: json['file_size_limit'] as int?, allowedMimeTypes: allowedMimeTypes is List @@ -65,8 +65,8 @@ class AnalyticsBucket { return AnalyticsBucket( id: json['id'] as String, name: json['name'] as String, - createdAt: DateTime.parse(json['created_at'] as String), - updatedAt: DateTime.parse(json['updated_at'] as String), + createdAt: parseIso8601(json, 'created_at'), + updatedAt: parseIso8601(json, 'updated_at'), ); } } @@ -76,8 +76,8 @@ class FileObject { final String? bucketId; final String? owner; final String? id; - final String? updatedAt; - final String? createdAt; + final DateTime? updatedAt; + final DateTime? createdAt; final Map? metadata; final Bucket? buckets; @@ -104,8 +104,8 @@ class FileObject { name: json['name'] as String, bucketId: json['bucket_id'] as String?, owner: json['owner'] as String?, - updatedAt: json['updated_at'] as String?, - createdAt: json['created_at'] as String?, + updatedAt: tryParseIso8601(json, 'updated_at'), + createdAt: tryParseIso8601(json, 'created_at'), metadata: json['metadata'] as Map?, buckets: bucketsJson is Map ? Bucket.fromJson(bucketsJson) @@ -119,13 +119,13 @@ class FileObjectV2 { final String version; final String name; final String bucketId; - final String? updatedAt; - final String createdAt; + final DateTime? updatedAt; + final DateTime createdAt; final int? size; final String? cacheControl; final String? contentType; final String? etag; - final String? lastModified; + final DateTime? lastModified; final Map? metadata; const FileObjectV2({ @@ -149,13 +149,13 @@ class FileObjectV2 { version: json['version'] as String, name: json['name'] as String, bucketId: json['bucket_id'] as String, - updatedAt: json['updated_at'] as String?, - createdAt: json['created_at'] as String, + updatedAt: tryParseIso8601(json, 'updated_at'), + createdAt: parseIso8601(json, 'created_at'), size: json['size'] as int?, cacheControl: json['cache_control'] as String?, contentType: json['content_type'] as String?, etag: json['etag'] as String?, - lastModified: json['last_modified'] as String?, + lastModified: tryParseIso8601(json, 'last_modified'), metadata: json['metadata'] as Map?, ); } @@ -405,10 +405,10 @@ class PaginatedFile { final String? id; /// The last update timestamp. - final String? updatedAt; + final DateTime? updatedAt; /// The creation timestamp. - final String? createdAt; + final DateTime? createdAt; /// The file metadata, including size and mimetype. `null` when not yet set. final Map? metadata; @@ -427,8 +427,8 @@ class PaginatedFile { name: json['name'] as String, key: json['key'] as String?, id: json['id'] as String?, - updatedAt: json['updated_at'] as String?, - createdAt: json['created_at'] as String?, + updatedAt: tryParseIso8601(json, 'updated_at'), + createdAt: tryParseIso8601(json, 'created_at'), metadata: json['metadata'] as Map?, ); } diff --git a/packages/storage_client/lib/src/vector_types.dart b/packages/storage_client/lib/src/vector_types.dart index 3f1dc806e..f98a3511d 100644 --- a/packages/storage_client/lib/src/vector_types.dart +++ b/packages/storage_client/lib/src/vector_types.dart @@ -1,4 +1,5 @@ import 'package:meta/meta.dart'; +import 'package:supabase_common/supabase_common.dart'; /// Supported data types for vector components. /// @@ -47,12 +48,11 @@ List? _parseFloat32(Object? data) { return float32.map((value) => (value as num).toDouble()).toList(); } +/// Parses a Unix timestamp in seconds, returning `null` for anything the S3 +/// Vectors API sends that is not a number. DateTime? _parseUnixSeconds(Object? value) { if (value is! num) return null; - return DateTime.fromMillisecondsSinceEpoch( - (value * 1000).round(), - isUtc: true, - ); + return dateTimeFromUnixSeconds(value); } /// Encryption settings attached to a vector bucket. diff --git a/packages/storage_client/test/basic_test.dart b/packages/storage_client/test/basic_test.dart index 5431164ef..2c63149d0 100644 --- a/packages/storage_client/test/basic_test.dart +++ b/packages/storage_client/test/basic_test.dart @@ -15,8 +15,8 @@ Map get testBucketJson => { 'id': 'test_bucket', 'name': 'test_bucket', 'owner': 'owner_id', - 'created_at': '', - 'updated_at': '', + 'created_at': '2024-01-01T00:00:00.000Z', + 'updated_at': '2024-01-02T00:00:00.000Z', 'public': false, }; diff --git a/packages/storage_client/test/types_test.dart b/packages/storage_client/test/types_test.dart index 1cbf55a9f..9b70686a9 100644 --- a/packages/storage_client/test/types_test.dart +++ b/packages/storage_client/test/types_test.dart @@ -17,6 +17,8 @@ void main() { expect(bucket.id, 'avatars'); expect(bucket.owner, 'owner-id'); + expect(bucket.createdAt, DateTime.utc(2021, 1, 1)); + expect(bucket.updatedAt, DateTime.utc(2021, 1, 2)); expect(bucket.public, isTrue); expect(bucket.fileSizeLimit, 1024); expect(bucket.allowedMimeTypes, ['image/png', 'image/jpeg']); @@ -36,6 +38,19 @@ void main() { expect(bucket.allowedMimeTypes, isNull); }); + test('throws when created_at is not a valid timestamp', () { + expect( + () => Bucket.fromJson({ + 'id': 'avatars', + 'name': 'avatars', + 'created_at': '', + 'updated_at': '2021-01-02T00:00:00Z', + 'public': false, + }), + throwsFormatException, + ); + }); + test('treats a non-list allowed_mime_types as null', () { final bucket = Bucket.fromJson({ 'id': 'avatars', @@ -78,6 +93,18 @@ void main() { expect(file.buckets, isNull); }); + test('parses the timestamps and leaves absent ones null', () { + final file = FileObject.fromJson({ + 'name': 'photo.png', + 'created_at': '2021-01-01T00:00:00Z', + 'updated_at': '2021-01-02T00:00:00+02:00', + }); + + expect(file.createdAt, DateTime.utc(2021, 1, 1)); + expect(file.updatedAt, DateTime.utc(2021, 1, 1, 22)); + expect(FileObject.fromJson({'name': 'photo.png'}).createdAt, isNull); + }); + test('throws a FormatException when the JSON is not an object', () { expect( () => FileObject.fromJson(['not', 'a', 'map']), @@ -104,7 +131,9 @@ void main() { expect(file.version, 'v1'); expect(file.size, 42); expect(file.contentType, 'image/png'); + expect(file.createdAt, DateTime.utc(2021, 1, 1)); expect(file.updatedAt, isNull); + expect(file.lastModified, isNull); }); }); diff --git a/packages/storage_client/test/vector_test.dart b/packages/storage_client/test/vector_test.dart index 398b66805..91f81c767 100644 --- a/packages/storage_client/test/vector_test.dart +++ b/packages/storage_client/test/vector_test.dart @@ -86,6 +86,19 @@ void main() { expect(result.nextToken, 'cursor-1'); }); + test('getBucket leaves a non-numeric creationTime null', () async { + mockClient.response = { + 'vectorBucket': { + 'vectorBucketName': 'embeddings', + 'creationTime': '2023-11-14T22:13:20Z', + }, + }; + + final bucket = await vectors.getBucket('embeddings'); + + expect(bucket.creationTime, isNull); + }); + test('deleteBucket posts the bucket name', () async { await vectors.deleteBucket('embeddings'); @@ -148,6 +161,33 @@ void main() { expect(index.nonFilterableMetadataKeys, ['raw_text']); }); + test('getIndex parses creationTime as UTC', () async { + mockClient.response = { + 'index': { + 'indexName': 'documents', + 'creationTime': 1700000000, + }, + }; + + final index = await vectors.from('embeddings').getIndex('documents'); + + expect(index.creationTime, DateTime.utc(2023, 11, 14, 22, 13, 20)); + expect(index.creationTime!.isUtc, isTrue); + }); + + test('getIndex leaves a non-numeric creationTime null', () async { + mockClient.response = { + 'index': { + 'indexName': 'documents', + 'creationTime': '2023-11-14T22:13:20Z', + }, + }; + + final index = await vectors.from('embeddings').getIndex('documents'); + + expect(index.creationTime, isNull); + }); + test('getIndex leaves unknown enum values null', () async { mockClient.response = { 'index': { diff --git a/packages/supabase_common/lib/src/timestamp.dart b/packages/supabase_common/lib/src/timestamp.dart new file mode 100644 index 000000000..5c00f1e11 --- /dev/null +++ b/packages/supabase_common/lib/src/timestamp.dart @@ -0,0 +1,96 @@ +/// Parses the ISO 8601 timestamp stored under [key] in [json] as a UTC +/// [DateTime]. +/// +/// Throws a [FormatException] when the value is missing, is not a string, or +/// is not a valid ISO 8601 timestamp. For the `YYYY-MM-DD` and `YYYYMMDD` +/// forms the servers send, a month or day that is out of range is rejected +/// rather than carried over into the next month or year the way +/// [DateTime.parse] carries it. +/// +/// The exception names the key and the offending value but does not carry +/// [json] itself, which holds the caller's payload and so may contain personal +/// data the caller would not expect in an error or a log. +DateTime parseIso8601(Map json, String key) { + final value = json[key]; + if (value is! String) { + throw FormatException( + 'Expected $key to be a string, got ${value.runtimeType}', + ); + } + final parsed = DateTime.tryParse(value); + if (parsed == null || !_hasValidCalendarDate(value)) { + throw FormatException('Invalid date format for $key: $value'); + } + return parsed.toUtc(); +} + +final _calendarDatePattern = RegExp( + r'^(?:(\d{4})-(\d{2})-(\d{2})|(\d{4})(\d{2})(\d{2}))', +); + +/// Whether the calendar date at the start of [value] is a real date. +/// +/// [DateTime.tryParse] carries out-of-range components over into the next +/// larger one instead of rejecting them, so `2020-01-42` parses as 2020-02-11 +/// and `2019-02-29` as 2019-03-01. A timestamp that does not name a real date +/// is a malformed payload rather than a timestamp days later. +/// +/// Only the `YYYY-MM-DD` and `YYYYMMDD` forms are checked. Anything else +/// [DateTime.tryParse] accepts, such as the expanded year form +/// `+002023-01-42`, keeps its carrying behaviour; no Supabase service sends +/// those. +bool _hasValidCalendarDate(String value) { + final match = _calendarDatePattern.firstMatch(value); + if (match == null) return true; + final year = int.parse(match[1] ?? match[4]!); + final month = int.parse(match[2] ?? match[5]!); + final day = int.parse(match[3] ?? match[6]!); + final carried = DateTime.utc(year, month, day); + return carried.month == month && carried.day == day; +} + +/// Same as [parseIso8601], but returns `null` when the value under [key] is +/// `null` or absent. +DateTime? tryParseIso8601(Map json, String key) { + if (json[key] == null) return null; + return parseIso8601(json, key); +} + +/// Parses the Unix timestamp in seconds stored under [key] in [json] as a UTC +/// [DateTime]. +/// +/// Throws a [FormatException] when the value is missing or is not a number. +/// As in [parseIso8601], the exception does not carry [json] itself. +DateTime parseUnixSeconds(Map json, String key) { + final value = json[key]; + if (value is! num) { + throw FormatException( + 'Expected $key to be a number, got ${value.runtimeType}', + ); + } + return dateTimeFromUnixSeconds(value); +} + +/// Same as [parseUnixSeconds], but returns `null` when the value under [key] is +/// `null` or absent. +DateTime? tryParseUnixSeconds(Map json, String key) { + if (json[key] == null) return null; + return parseUnixSeconds(json, key); +} + +/// Converts a Unix timestamp in [seconds] to a UTC [DateTime]. +DateTime dateTimeFromUnixSeconds(num seconds) { + return DateTime.fromMillisecondsSinceEpoch( + (seconds * 1000).round(), + isUtc: true, + ); +} + +/// The Unix timestamp of [dateTime] in whole seconds. +/// +/// Sub-second precision is floored rather than truncated towards zero, so the +/// result stays the second that contains [dateTime] for instants before the +/// Unix epoch too. +int unixSecondsFromDateTime(DateTime dateTime) { + return (dateTime.millisecondsSinceEpoch / 1000).floor(); +} diff --git a/packages/supabase_common/lib/supabase_common.dart b/packages/supabase_common/lib/supabase_common.dart index ae17fc531..ceade8799 100644 --- a/packages/supabase_common/lib/supabase_common.dart +++ b/packages/supabase_common/lib/supabase_common.dart @@ -14,4 +14,5 @@ export 'src/platform/platform_info.dart'; export 'src/replay_subject.dart'; export 'src/retry.dart'; export 'src/snake_case.dart'; +export 'src/timestamp.dart'; export 'src/uuid.dart'; diff --git a/packages/supabase_common/test/timestamp_test.dart b/packages/supabase_common/test/timestamp_test.dart new file mode 100644 index 000000000..e49f7fe7d --- /dev/null +++ b/packages/supabase_common/test/timestamp_test.dart @@ -0,0 +1,241 @@ +import 'package:supabase_common/supabase_common.dart'; +import 'package:test/test.dart'; + +void main() { + group('parseIso8601', () { + test('parses a UTC timestamp', () { + expect( + parseIso8601({ + 'created_at': '2023-04-01T09:38:59.784028Z', + }, 'created_at'), + DateTime.utc(2023, 4, 1, 9, 38, 59, 784, 28), + ); + }); + + test('normalizes a timestamp with an offset to UTC', () { + final parsed = parseIso8601({ + 'created_at': '2023-04-01T11:00:00+02:00', + }, 'created_at'); + + expect(parsed.isUtc, isTrue); + expect(parsed, DateTime.utc(2023, 4, 1, 9)); + }); + + test('normalizes a timestamp without a zone designator to UTC', () { + final parsed = parseIso8601({ + 'created_at': '2023-04-01T09:00:00', + }, 'created_at'); + + expect(parsed.isUtc, isTrue); + expect(parsed, DateTime(2023, 4, 1, 9).toUtc()); + }); + + test('throws when the value is missing', () { + expect( + () => parseIso8601({}, 'created_at'), + throwsA( + isA().having( + (exception) => exception.message, + 'message', + 'Expected created_at to be a string, got Null', + ), + ), + ); + }); + + test('throws when the value is not a string', () { + expect( + () => parseIso8601({'created_at': 1735689600}, 'created_at'), + throwsFormatException, + ); + }); + + test('throws when the value is not a valid timestamp', () { + expect( + () => parseIso8601({'created_at': 'yesterday'}, 'created_at'), + throwsA( + isA().having( + (exception) => exception.message, + 'message', + 'Invalid date format for created_at: yesterday', + ), + ), + ); + }); + + test('does not carry the payload into the exception', () { + // The payload can hold personal data, and these exceptions get logged. + expect( + () => parseIso8601({ + 'email': 'jane.doe@example.com', + 'created_at': 'yesterday', + }, 'created_at'), + throwsA( + isA() + .having((exception) => exception.source, 'source', isNull) + .having( + (exception) => exception.toString(), + 'toString', + isNot(contains('jane.doe@example.com')), + ), + ), + ); + }); + + test('throws instead of carrying an out-of-range day into a real one', () { + // DateTime.parse would return 2020-02-11 for this. + expect( + () => parseIso8601({'created_at': '2020-01-42'}, 'created_at'), + throwsFormatException, + ); + expect( + () => + parseIso8601({'created_at': '2025-02-30T10:00:00Z'}, 'created_at'), + throwsFormatException, + ); + expect( + () => + parseIso8601({'created_at': '2019-02-29T10:00:00Z'}, 'created_at'), + throwsFormatException, + ); + expect( + () => parseIso8601({'created_at': '2020-01-00'}, 'created_at'), + throwsFormatException, + ); + }); + + test( + 'throws instead of carrying an out-of-range month into a real one', + () { + expect( + () => parseIso8601({'created_at': '2023-13-01'}, 'created_at'), + throwsFormatException, + ); + expect( + () => parseIso8601({'created_at': '2023-00-01'}, 'created_at'), + throwsFormatException, + ); + }, + ); + + test('accepts a leap day in a leap year', () { + expect( + parseIso8601({'created_at': '2020-02-29T10:00:00Z'}, 'created_at'), + DateTime.utc(2020, 2, 29, 10), + ); + }); + + test('accepts the basic format the ISO 8601 parser allows', () { + expect( + parseIso8601({'created_at': '20230401T090000Z'}, 'created_at'), + DateTime.utc(2023, 4, 1, 9), + ); + }); + + test('rejects an out-of-range day in the basic format', () { + expect( + () => parseIso8601({'created_at': '20230442T090000Z'}, 'created_at'), + throwsFormatException, + ); + }); + }); + + group('tryParseIso8601', () { + test('returns null when the value is null', () { + expect(tryParseIso8601({'created_at': null}, 'created_at'), isNull); + }); + + test('returns null when the value is absent', () { + expect(tryParseIso8601({}, 'created_at'), isNull); + }); + + test('parses a present value', () { + expect( + tryParseIso8601({'created_at': '2023-04-01T09:00:00Z'}, 'created_at'), + DateTime.utc(2023, 4, 1, 9), + ); + }); + + test('throws when a present value is not a valid timestamp', () { + expect( + () => tryParseIso8601({'created_at': 'yesterday'}, 'created_at'), + throwsFormatException, + ); + }); + }); + + group('parseUnixSeconds', () { + test('parses whole seconds as UTC', () { + final parsed = parseUnixSeconds({'expires_at': 1735689600}, 'expires_at'); + + expect(parsed.isUtc, isTrue); + expect(parsed, DateTime.utc(2025, 1, 1)); + }); + + test('rounds fractional seconds to the nearest millisecond', () { + expect( + parseUnixSeconds({'expires_at': 1735689600.4567}, 'expires_at'), + DateTime.utc(2025, 1, 1, 0, 0, 0, 457), + ); + }); + + test('throws when the value is not a number', () { + expect( + () => parseUnixSeconds({'expires_at': '1735689600'}, 'expires_at'), + throwsA( + isA().having( + (exception) => exception.message, + 'message', + 'Expected expires_at to be a number, got String', + ), + ), + ); + }); + }); + + group('tryParseUnixSeconds', () { + test('returns null when the value is null', () { + expect(tryParseUnixSeconds({'expires_at': null}, 'expires_at'), isNull); + }); + + test('parses a present value', () { + expect( + tryParseUnixSeconds({'expires_at': 1735689600}, 'expires_at'), + DateTime.utc(2025, 1, 1), + ); + }); + }); + + group('dateTimeFromUnixSeconds and unixSecondsFromDateTime', () { + test('round trip whole seconds', () { + expect( + unixSecondsFromDateTime(dateTimeFromUnixSeconds(1735689600)), + 1735689600, + ); + }); + + test('unixSecondsFromDateTime floors sub-second precision', () { + expect( + unixSecondsFromDateTime(DateTime.utc(2025, 1, 1, 0, 0, 0, 999)), + 1735689600, + ); + }); + + test('unixSecondsFromDateTime floors before the epoch too', () { + // Truncating towards zero would give 0 and -1 here, seconds that do not + // contain these instants. + expect( + unixSecondsFromDateTime(DateTime.utc(1969, 12, 31, 23, 59, 59, 500)), + -1, + ); + expect( + unixSecondsFromDateTime(DateTime.utc(1969, 12, 31, 23, 59, 58, 500)), + -2, + ); + }); + + test('round trip an instant before the epoch', () { + expect(unixSecondsFromDateTime(dateTimeFromUnixSeconds(-2)), -2); + }); + }); +} diff --git a/sdk-compliance.yaml b/sdk-compliance.yaml index d69a03d63..81ff2633c 100644 --- a/sdk-compliance.yaml +++ b/sdk-compliance.yaml @@ -208,6 +208,11 @@ features: - OAuthAuthorizedClient.clientId - OAuthAuthorizedClient.clientName - OAuthAuthorizedClient.fromJson + - OAuthAuthorizingUser + - OAuthAuthorizingUser.OAuthAuthorizingUser + - OAuthAuthorizingUser.email + - OAuthAuthorizingUser.fromJson + - OAuthAuthorizingUser.id auth.oauth_server.approve_authorization: status: implemented symbols: