mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 20:07:41 +09:00 
			
		
		
		
	feat: sync albums and album users (#18377)
This commit is contained in:
		
							
								
								
									
										4
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							| @@ -443,6 +443,10 @@ Class | Method | HTTP request | Description | ||||
|  - [SyncAckDeleteDto](doc//SyncAckDeleteDto.md) | ||||
|  - [SyncAckDto](doc//SyncAckDto.md) | ||||
|  - [SyncAckSetDto](doc//SyncAckSetDto.md) | ||||
|  - [SyncAlbumDeleteV1](doc//SyncAlbumDeleteV1.md) | ||||
|  - [SyncAlbumUserDeleteV1](doc//SyncAlbumUserDeleteV1.md) | ||||
|  - [SyncAlbumUserV1](doc//SyncAlbumUserV1.md) | ||||
|  - [SyncAlbumV1](doc//SyncAlbumV1.md) | ||||
|  - [SyncAssetDeleteV1](doc//SyncAssetDeleteV1.md) | ||||
|  - [SyncAssetExifV1](doc//SyncAssetExifV1.md) | ||||
|  - [SyncAssetV1](doc//SyncAssetV1.md) | ||||
|   | ||||
							
								
								
									
										4
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							| @@ -238,6 +238,10 @@ part 'model/stack_update_dto.dart'; | ||||
| part 'model/sync_ack_delete_dto.dart'; | ||||
| part 'model/sync_ack_dto.dart'; | ||||
| part 'model/sync_ack_set_dto.dart'; | ||||
| part 'model/sync_album_delete_v1.dart'; | ||||
| part 'model/sync_album_user_delete_v1.dart'; | ||||
| part 'model/sync_album_user_v1.dart'; | ||||
| part 'model/sync_album_v1.dart'; | ||||
| part 'model/sync_asset_delete_v1.dart'; | ||||
| part 'model/sync_asset_exif_v1.dart'; | ||||
| part 'model/sync_asset_v1.dart'; | ||||
|   | ||||
							
								
								
									
										8
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										8
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							| @@ -532,6 +532,14 @@ class ApiClient { | ||||
|           return SyncAckDto.fromJson(value); | ||||
|         case 'SyncAckSetDto': | ||||
|           return SyncAckSetDto.fromJson(value); | ||||
|         case 'SyncAlbumDeleteV1': | ||||
|           return SyncAlbumDeleteV1.fromJson(value); | ||||
|         case 'SyncAlbumUserDeleteV1': | ||||
|           return SyncAlbumUserDeleteV1.fromJson(value); | ||||
|         case 'SyncAlbumUserV1': | ||||
|           return SyncAlbumUserV1.fromJson(value); | ||||
|         case 'SyncAlbumV1': | ||||
|           return SyncAlbumV1.fromJson(value); | ||||
|         case 'SyncAssetDeleteV1': | ||||
|           return SyncAssetDeleteV1.fromJson(value); | ||||
|         case 'SyncAssetExifV1': | ||||
|   | ||||
							
								
								
									
										99
									
								
								mobile/openapi/lib/model/sync_album_delete_v1.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								mobile/openapi/lib/model/sync_album_delete_v1.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,99 @@ | ||||
| // | ||||
| // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||
| // | ||||
| // @dart=2.18 | ||||
| 
 | ||||
| // ignore_for_file: unused_element, unused_import | ||||
| // ignore_for_file: always_put_required_named_parameters_first | ||||
| // ignore_for_file: constant_identifier_names | ||||
| // ignore_for_file: lines_longer_than_80_chars | ||||
| 
 | ||||
| part of openapi.api; | ||||
| 
 | ||||
| class SyncAlbumDeleteV1 { | ||||
|   /// Returns a new [SyncAlbumDeleteV1] instance. | ||||
|   SyncAlbumDeleteV1({ | ||||
|     required this.albumId, | ||||
|   }); | ||||
| 
 | ||||
|   String albumId; | ||||
| 
 | ||||
|   @override | ||||
|   bool operator ==(Object other) => identical(this, other) || other is SyncAlbumDeleteV1 && | ||||
|     other.albumId == albumId; | ||||
| 
 | ||||
|   @override | ||||
|   int get hashCode => | ||||
|     // ignore: unnecessary_parenthesis | ||||
|     (albumId.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'SyncAlbumDeleteV1[albumId=$albumId]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
|       json[r'albumId'] = this.albumId; | ||||
|     return json; | ||||
|   } | ||||
| 
 | ||||
|   /// Returns a new [SyncAlbumDeleteV1] instance and imports its values from | ||||
|   /// [value] if it's a [Map], null otherwise. | ||||
|   // ignore: prefer_constructors_over_static_methods | ||||
|   static SyncAlbumDeleteV1? fromJson(dynamic value) { | ||||
|     upgradeDto(value, "SyncAlbumDeleteV1"); | ||||
|     if (value is Map) { | ||||
|       final json = value.cast<String, dynamic>(); | ||||
| 
 | ||||
|       return SyncAlbumDeleteV1( | ||||
|         albumId: mapValueOfType<String>(json, r'albumId')!, | ||||
|       ); | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   static List<SyncAlbumDeleteV1> listFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final result = <SyncAlbumDeleteV1>[]; | ||||
|     if (json is List && json.isNotEmpty) { | ||||
|       for (final row in json) { | ||||
|         final value = SyncAlbumDeleteV1.fromJson(row); | ||||
|         if (value != null) { | ||||
|           result.add(value); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return result.toList(growable: growable); | ||||
|   } | ||||
| 
 | ||||
|   static Map<String, SyncAlbumDeleteV1> mapFromJson(dynamic json) { | ||||
|     final map = <String, SyncAlbumDeleteV1>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       json = json.cast<String, dynamic>(); // ignore: parameter_assignments | ||||
|       for (final entry in json.entries) { | ||||
|         final value = SyncAlbumDeleteV1.fromJson(entry.value); | ||||
|         if (value != null) { | ||||
|           map[entry.key] = value; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return map; | ||||
|   } | ||||
| 
 | ||||
|   // maps a json object with a list of SyncAlbumDeleteV1-objects as value to a dart map | ||||
|   static Map<String, List<SyncAlbumDeleteV1>> mapListFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final map = <String, List<SyncAlbumDeleteV1>>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       // ignore: parameter_assignments | ||||
|       json = json.cast<String, dynamic>(); | ||||
|       for (final entry in json.entries) { | ||||
|         map[entry.key] = SyncAlbumDeleteV1.listFromJson(entry.value, growable: growable,); | ||||
|       } | ||||
|     } | ||||
|     return map; | ||||
|   } | ||||
| 
 | ||||
|   /// The list of required keys that must be present in a JSON. | ||||
|   static const requiredKeys = <String>{ | ||||
|     'albumId', | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
							
								
								
									
										107
									
								
								mobile/openapi/lib/model/sync_album_user_delete_v1.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								mobile/openapi/lib/model/sync_album_user_delete_v1.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,107 @@ | ||||
| // | ||||
| // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||
| // | ||||
| // @dart=2.18 | ||||
| 
 | ||||
| // ignore_for_file: unused_element, unused_import | ||||
| // ignore_for_file: always_put_required_named_parameters_first | ||||
| // ignore_for_file: constant_identifier_names | ||||
| // ignore_for_file: lines_longer_than_80_chars | ||||
| 
 | ||||
| part of openapi.api; | ||||
| 
 | ||||
| class SyncAlbumUserDeleteV1 { | ||||
|   /// Returns a new [SyncAlbumUserDeleteV1] instance. | ||||
|   SyncAlbumUserDeleteV1({ | ||||
|     required this.albumId, | ||||
|     required this.userId, | ||||
|   }); | ||||
| 
 | ||||
|   String albumId; | ||||
| 
 | ||||
|   String userId; | ||||
| 
 | ||||
|   @override | ||||
|   bool operator ==(Object other) => identical(this, other) || other is SyncAlbumUserDeleteV1 && | ||||
|     other.albumId == albumId && | ||||
|     other.userId == userId; | ||||
| 
 | ||||
|   @override | ||||
|   int get hashCode => | ||||
|     // ignore: unnecessary_parenthesis | ||||
|     (albumId.hashCode) + | ||||
|     (userId.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'SyncAlbumUserDeleteV1[albumId=$albumId, userId=$userId]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
|       json[r'albumId'] = this.albumId; | ||||
|       json[r'userId'] = this.userId; | ||||
|     return json; | ||||
|   } | ||||
| 
 | ||||
|   /// Returns a new [SyncAlbumUserDeleteV1] instance and imports its values from | ||||
|   /// [value] if it's a [Map], null otherwise. | ||||
|   // ignore: prefer_constructors_over_static_methods | ||||
|   static SyncAlbumUserDeleteV1? fromJson(dynamic value) { | ||||
|     upgradeDto(value, "SyncAlbumUserDeleteV1"); | ||||
|     if (value is Map) { | ||||
|       final json = value.cast<String, dynamic>(); | ||||
| 
 | ||||
|       return SyncAlbumUserDeleteV1( | ||||
|         albumId: mapValueOfType<String>(json, r'albumId')!, | ||||
|         userId: mapValueOfType<String>(json, r'userId')!, | ||||
|       ); | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   static List<SyncAlbumUserDeleteV1> listFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final result = <SyncAlbumUserDeleteV1>[]; | ||||
|     if (json is List && json.isNotEmpty) { | ||||
|       for (final row in json) { | ||||
|         final value = SyncAlbumUserDeleteV1.fromJson(row); | ||||
|         if (value != null) { | ||||
|           result.add(value); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return result.toList(growable: growable); | ||||
|   } | ||||
| 
 | ||||
|   static Map<String, SyncAlbumUserDeleteV1> mapFromJson(dynamic json) { | ||||
|     final map = <String, SyncAlbumUserDeleteV1>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       json = json.cast<String, dynamic>(); // ignore: parameter_assignments | ||||
|       for (final entry in json.entries) { | ||||
|         final value = SyncAlbumUserDeleteV1.fromJson(entry.value); | ||||
|         if (value != null) { | ||||
|           map[entry.key] = value; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return map; | ||||
|   } | ||||
| 
 | ||||
|   // maps a json object with a list of SyncAlbumUserDeleteV1-objects as value to a dart map | ||||
|   static Map<String, List<SyncAlbumUserDeleteV1>> mapListFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final map = <String, List<SyncAlbumUserDeleteV1>>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       // ignore: parameter_assignments | ||||
|       json = json.cast<String, dynamic>(); | ||||
|       for (final entry in json.entries) { | ||||
|         map[entry.key] = SyncAlbumUserDeleteV1.listFromJson(entry.value, growable: growable,); | ||||
|       } | ||||
|     } | ||||
|     return map; | ||||
|   } | ||||
| 
 | ||||
|   /// The list of required keys that must be present in a JSON. | ||||
|   static const requiredKeys = <String>{ | ||||
|     'albumId', | ||||
|     'userId', | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
							
								
								
									
										189
									
								
								mobile/openapi/lib/model/sync_album_user_v1.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										189
									
								
								mobile/openapi/lib/model/sync_album_user_v1.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,189 @@ | ||||
| // | ||||
| // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||
| // | ||||
| // @dart=2.18 | ||||
| 
 | ||||
| // ignore_for_file: unused_element, unused_import | ||||
| // ignore_for_file: always_put_required_named_parameters_first | ||||
| // ignore_for_file: constant_identifier_names | ||||
| // ignore_for_file: lines_longer_than_80_chars | ||||
| 
 | ||||
| part of openapi.api; | ||||
| 
 | ||||
| class SyncAlbumUserV1 { | ||||
|   /// Returns a new [SyncAlbumUserV1] instance. | ||||
|   SyncAlbumUserV1({ | ||||
|     required this.albumId, | ||||
|     required this.role, | ||||
|     required this.userId, | ||||
|   }); | ||||
| 
 | ||||
|   String albumId; | ||||
| 
 | ||||
|   SyncAlbumUserV1RoleEnum role; | ||||
| 
 | ||||
|   String userId; | ||||
| 
 | ||||
|   @override | ||||
|   bool operator ==(Object other) => identical(this, other) || other is SyncAlbumUserV1 && | ||||
|     other.albumId == albumId && | ||||
|     other.role == role && | ||||
|     other.userId == userId; | ||||
| 
 | ||||
|   @override | ||||
|   int get hashCode => | ||||
|     // ignore: unnecessary_parenthesis | ||||
|     (albumId.hashCode) + | ||||
|     (role.hashCode) + | ||||
|     (userId.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'SyncAlbumUserV1[albumId=$albumId, role=$role, userId=$userId]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
|       json[r'albumId'] = this.albumId; | ||||
|       json[r'role'] = this.role; | ||||
|       json[r'userId'] = this.userId; | ||||
|     return json; | ||||
|   } | ||||
| 
 | ||||
|   /// Returns a new [SyncAlbumUserV1] instance and imports its values from | ||||
|   /// [value] if it's a [Map], null otherwise. | ||||
|   // ignore: prefer_constructors_over_static_methods | ||||
|   static SyncAlbumUserV1? fromJson(dynamic value) { | ||||
|     upgradeDto(value, "SyncAlbumUserV1"); | ||||
|     if (value is Map) { | ||||
|       final json = value.cast<String, dynamic>(); | ||||
| 
 | ||||
|       return SyncAlbumUserV1( | ||||
|         albumId: mapValueOfType<String>(json, r'albumId')!, | ||||
|         role: SyncAlbumUserV1RoleEnum.fromJson(json[r'role'])!, | ||||
|         userId: mapValueOfType<String>(json, r'userId')!, | ||||
|       ); | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   static List<SyncAlbumUserV1> listFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final result = <SyncAlbumUserV1>[]; | ||||
|     if (json is List && json.isNotEmpty) { | ||||
|       for (final row in json) { | ||||
|         final value = SyncAlbumUserV1.fromJson(row); | ||||
|         if (value != null) { | ||||
|           result.add(value); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return result.toList(growable: growable); | ||||
|   } | ||||
| 
 | ||||
|   static Map<String, SyncAlbumUserV1> mapFromJson(dynamic json) { | ||||
|     final map = <String, SyncAlbumUserV1>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       json = json.cast<String, dynamic>(); // ignore: parameter_assignments | ||||
|       for (final entry in json.entries) { | ||||
|         final value = SyncAlbumUserV1.fromJson(entry.value); | ||||
|         if (value != null) { | ||||
|           map[entry.key] = value; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return map; | ||||
|   } | ||||
| 
 | ||||
|   // maps a json object with a list of SyncAlbumUserV1-objects as value to a dart map | ||||
|   static Map<String, List<SyncAlbumUserV1>> mapListFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final map = <String, List<SyncAlbumUserV1>>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       // ignore: parameter_assignments | ||||
|       json = json.cast<String, dynamic>(); | ||||
|       for (final entry in json.entries) { | ||||
|         map[entry.key] = SyncAlbumUserV1.listFromJson(entry.value, growable: growable,); | ||||
|       } | ||||
|     } | ||||
|     return map; | ||||
|   } | ||||
| 
 | ||||
|   /// The list of required keys that must be present in a JSON. | ||||
|   static const requiredKeys = <String>{ | ||||
|     'albumId', | ||||
|     'role', | ||||
|     'userId', | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| class SyncAlbumUserV1RoleEnum { | ||||
|   /// Instantiate a new enum with the provided [value]. | ||||
|   const SyncAlbumUserV1RoleEnum._(this.value); | ||||
| 
 | ||||
|   /// The underlying value of this enum member. | ||||
|   final String value; | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => value; | ||||
| 
 | ||||
|   String toJson() => value; | ||||
| 
 | ||||
|   static const editor = SyncAlbumUserV1RoleEnum._(r'editor'); | ||||
|   static const viewer = SyncAlbumUserV1RoleEnum._(r'viewer'); | ||||
| 
 | ||||
|   /// List of all possible values in this [enum][SyncAlbumUserV1RoleEnum]. | ||||
|   static const values = <SyncAlbumUserV1RoleEnum>[ | ||||
|     editor, | ||||
|     viewer, | ||||
|   ]; | ||||
| 
 | ||||
|   static SyncAlbumUserV1RoleEnum? fromJson(dynamic value) => SyncAlbumUserV1RoleEnumTypeTransformer().decode(value); | ||||
| 
 | ||||
|   static List<SyncAlbumUserV1RoleEnum> listFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final result = <SyncAlbumUserV1RoleEnum>[]; | ||||
|     if (json is List && json.isNotEmpty) { | ||||
|       for (final row in json) { | ||||
|         final value = SyncAlbumUserV1RoleEnum.fromJson(row); | ||||
|         if (value != null) { | ||||
|           result.add(value); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return result.toList(growable: growable); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /// Transformation class that can [encode] an instance of [SyncAlbumUserV1RoleEnum] to String, | ||||
| /// and [decode] dynamic data back to [SyncAlbumUserV1RoleEnum]. | ||||
| class SyncAlbumUserV1RoleEnumTypeTransformer { | ||||
|   factory SyncAlbumUserV1RoleEnumTypeTransformer() => _instance ??= const SyncAlbumUserV1RoleEnumTypeTransformer._(); | ||||
| 
 | ||||
|   const SyncAlbumUserV1RoleEnumTypeTransformer._(); | ||||
| 
 | ||||
|   String encode(SyncAlbumUserV1RoleEnum data) => data.value; | ||||
| 
 | ||||
|   /// Decodes a [dynamic value][data] to a SyncAlbumUserV1RoleEnum. | ||||
|   /// | ||||
|   /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, | ||||
|   /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] | ||||
|   /// cannot be decoded successfully, then an [UnimplementedError] is thrown. | ||||
|   /// | ||||
|   /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, | ||||
|   /// and users are still using an old app with the old code. | ||||
|   SyncAlbumUserV1RoleEnum? decode(dynamic data, {bool allowNull = true}) { | ||||
|     if (data != null) { | ||||
|       switch (data) { | ||||
|         case r'editor': return SyncAlbumUserV1RoleEnum.editor; | ||||
|         case r'viewer': return SyncAlbumUserV1RoleEnum.viewer; | ||||
|         default: | ||||
|           if (!allowNull) { | ||||
|             throw ArgumentError('Unknown enum value to decode: $data'); | ||||
|           } | ||||
|       } | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   /// Singleton [SyncAlbumUserV1RoleEnumTypeTransformer] instance. | ||||
|   static SyncAlbumUserV1RoleEnumTypeTransformer? _instance; | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
							
								
								
									
										167
									
								
								mobile/openapi/lib/model/sync_album_v1.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										167
									
								
								mobile/openapi/lib/model/sync_album_v1.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,167 @@ | ||||
| // | ||||
| // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||
| // | ||||
| // @dart=2.18 | ||||
| 
 | ||||
| // ignore_for_file: unused_element, unused_import | ||||
| // ignore_for_file: always_put_required_named_parameters_first | ||||
| // ignore_for_file: constant_identifier_names | ||||
| // ignore_for_file: lines_longer_than_80_chars | ||||
| 
 | ||||
| part of openapi.api; | ||||
| 
 | ||||
| class SyncAlbumV1 { | ||||
|   /// Returns a new [SyncAlbumV1] instance. | ||||
|   SyncAlbumV1({ | ||||
|     required this.createdAt, | ||||
|     required this.description, | ||||
|     required this.id, | ||||
|     required this.isActivityEnabled, | ||||
|     required this.name, | ||||
|     required this.order, | ||||
|     required this.ownerId, | ||||
|     required this.thumbnailAssetId, | ||||
|     required this.updatedAt, | ||||
|   }); | ||||
| 
 | ||||
|   DateTime createdAt; | ||||
| 
 | ||||
|   String description; | ||||
| 
 | ||||
|   String id; | ||||
| 
 | ||||
|   bool isActivityEnabled; | ||||
| 
 | ||||
|   String name; | ||||
| 
 | ||||
|   AssetOrder order; | ||||
| 
 | ||||
|   String ownerId; | ||||
| 
 | ||||
|   String? thumbnailAssetId; | ||||
| 
 | ||||
|   DateTime updatedAt; | ||||
| 
 | ||||
|   @override | ||||
|   bool operator ==(Object other) => identical(this, other) || other is SyncAlbumV1 && | ||||
|     other.createdAt == createdAt && | ||||
|     other.description == description && | ||||
|     other.id == id && | ||||
|     other.isActivityEnabled == isActivityEnabled && | ||||
|     other.name == name && | ||||
|     other.order == order && | ||||
|     other.ownerId == ownerId && | ||||
|     other.thumbnailAssetId == thumbnailAssetId && | ||||
|     other.updatedAt == updatedAt; | ||||
| 
 | ||||
|   @override | ||||
|   int get hashCode => | ||||
|     // ignore: unnecessary_parenthesis | ||||
|     (createdAt.hashCode) + | ||||
|     (description.hashCode) + | ||||
|     (id.hashCode) + | ||||
|     (isActivityEnabled.hashCode) + | ||||
|     (name.hashCode) + | ||||
|     (order.hashCode) + | ||||
|     (ownerId.hashCode) + | ||||
|     (thumbnailAssetId == null ? 0 : thumbnailAssetId!.hashCode) + | ||||
|     (updatedAt.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'SyncAlbumV1[createdAt=$createdAt, description=$description, id=$id, isActivityEnabled=$isActivityEnabled, name=$name, order=$order, ownerId=$ownerId, thumbnailAssetId=$thumbnailAssetId, updatedAt=$updatedAt]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
|       json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); | ||||
|       json[r'description'] = this.description; | ||||
|       json[r'id'] = this.id; | ||||
|       json[r'isActivityEnabled'] = this.isActivityEnabled; | ||||
|       json[r'name'] = this.name; | ||||
|       json[r'order'] = this.order; | ||||
|       json[r'ownerId'] = this.ownerId; | ||||
|     if (this.thumbnailAssetId != null) { | ||||
|       json[r'thumbnailAssetId'] = this.thumbnailAssetId; | ||||
|     } else { | ||||
|     //  json[r'thumbnailAssetId'] = null; | ||||
|     } | ||||
|       json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); | ||||
|     return json; | ||||
|   } | ||||
| 
 | ||||
|   /// Returns a new [SyncAlbumV1] instance and imports its values from | ||||
|   /// [value] if it's a [Map], null otherwise. | ||||
|   // ignore: prefer_constructors_over_static_methods | ||||
|   static SyncAlbumV1? fromJson(dynamic value) { | ||||
|     upgradeDto(value, "SyncAlbumV1"); | ||||
|     if (value is Map) { | ||||
|       final json = value.cast<String, dynamic>(); | ||||
| 
 | ||||
|       return SyncAlbumV1( | ||||
|         createdAt: mapDateTime(json, r'createdAt', r'')!, | ||||
|         description: mapValueOfType<String>(json, r'description')!, | ||||
|         id: mapValueOfType<String>(json, r'id')!, | ||||
|         isActivityEnabled: mapValueOfType<bool>(json, r'isActivityEnabled')!, | ||||
|         name: mapValueOfType<String>(json, r'name')!, | ||||
|         order: AssetOrder.fromJson(json[r'order'])!, | ||||
|         ownerId: mapValueOfType<String>(json, r'ownerId')!, | ||||
|         thumbnailAssetId: mapValueOfType<String>(json, r'thumbnailAssetId'), | ||||
|         updatedAt: mapDateTime(json, r'updatedAt', r'')!, | ||||
|       ); | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   static List<SyncAlbumV1> listFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final result = <SyncAlbumV1>[]; | ||||
|     if (json is List && json.isNotEmpty) { | ||||
|       for (final row in json) { | ||||
|         final value = SyncAlbumV1.fromJson(row); | ||||
|         if (value != null) { | ||||
|           result.add(value); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return result.toList(growable: growable); | ||||
|   } | ||||
| 
 | ||||
|   static Map<String, SyncAlbumV1> mapFromJson(dynamic json) { | ||||
|     final map = <String, SyncAlbumV1>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       json = json.cast<String, dynamic>(); // ignore: parameter_assignments | ||||
|       for (final entry in json.entries) { | ||||
|         final value = SyncAlbumV1.fromJson(entry.value); | ||||
|         if (value != null) { | ||||
|           map[entry.key] = value; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return map; | ||||
|   } | ||||
| 
 | ||||
|   // maps a json object with a list of SyncAlbumV1-objects as value to a dart map | ||||
|   static Map<String, List<SyncAlbumV1>> mapListFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final map = <String, List<SyncAlbumV1>>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       // ignore: parameter_assignments | ||||
|       json = json.cast<String, dynamic>(); | ||||
|       for (final entry in json.entries) { | ||||
|         map[entry.key] = SyncAlbumV1.listFromJson(entry.value, growable: growable,); | ||||
|       } | ||||
|     } | ||||
|     return map; | ||||
|   } | ||||
| 
 | ||||
|   /// The list of required keys that must be present in a JSON. | ||||
|   static const requiredKeys = <String>{ | ||||
|     'createdAt', | ||||
|     'description', | ||||
|     'id', | ||||
|     'isActivityEnabled', | ||||
|     'name', | ||||
|     'order', | ||||
|     'ownerId', | ||||
|     'thumbnailAssetId', | ||||
|     'updatedAt', | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
							
								
								
									
										12
									
								
								mobile/openapi/lib/model/sync_entity_type.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										12
									
								
								mobile/openapi/lib/model/sync_entity_type.dart
									
									
									
										generated
									
									
									
								
							| @@ -33,6 +33,10 @@ class SyncEntityType { | ||||
|   static const partnerAssetV1 = SyncEntityType._(r'PartnerAssetV1'); | ||||
|   static const partnerAssetDeleteV1 = SyncEntityType._(r'PartnerAssetDeleteV1'); | ||||
|   static const partnerAssetExifV1 = SyncEntityType._(r'PartnerAssetExifV1'); | ||||
|   static const albumV1 = SyncEntityType._(r'AlbumV1'); | ||||
|   static const albumDeleteV1 = SyncEntityType._(r'AlbumDeleteV1'); | ||||
|   static const albumUserV1 = SyncEntityType._(r'AlbumUserV1'); | ||||
|   static const albumUserDeleteV1 = SyncEntityType._(r'AlbumUserDeleteV1'); | ||||
| 
 | ||||
|   /// List of all possible values in this [enum][SyncEntityType]. | ||||
|   static const values = <SyncEntityType>[ | ||||
| @@ -46,6 +50,10 @@ class SyncEntityType { | ||||
|     partnerAssetV1, | ||||
|     partnerAssetDeleteV1, | ||||
|     partnerAssetExifV1, | ||||
|     albumV1, | ||||
|     albumDeleteV1, | ||||
|     albumUserV1, | ||||
|     albumUserDeleteV1, | ||||
|   ]; | ||||
| 
 | ||||
|   static SyncEntityType? fromJson(dynamic value) => SyncEntityTypeTypeTransformer().decode(value); | ||||
| @@ -94,6 +102,10 @@ class SyncEntityTypeTypeTransformer { | ||||
|         case r'PartnerAssetV1': return SyncEntityType.partnerAssetV1; | ||||
|         case r'PartnerAssetDeleteV1': return SyncEntityType.partnerAssetDeleteV1; | ||||
|         case r'PartnerAssetExifV1': return SyncEntityType.partnerAssetExifV1; | ||||
|         case r'AlbumV1': return SyncEntityType.albumV1; | ||||
|         case r'AlbumDeleteV1': return SyncEntityType.albumDeleteV1; | ||||
|         case r'AlbumUserV1': return SyncEntityType.albumUserV1; | ||||
|         case r'AlbumUserDeleteV1': return SyncEntityType.albumUserDeleteV1; | ||||
|         default: | ||||
|           if (!allowNull) { | ||||
|             throw ArgumentError('Unknown enum value to decode: $data'); | ||||
|   | ||||
							
								
								
									
										6
									
								
								mobile/openapi/lib/model/sync_request_type.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										6
									
								
								mobile/openapi/lib/model/sync_request_type.dart
									
									
									
										generated
									
									
									
								
							| @@ -29,6 +29,8 @@ class SyncRequestType { | ||||
|   static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1'); | ||||
|   static const partnerAssetsV1 = SyncRequestType._(r'PartnerAssetsV1'); | ||||
|   static const partnerAssetExifsV1 = SyncRequestType._(r'PartnerAssetExifsV1'); | ||||
|   static const albumsV1 = SyncRequestType._(r'AlbumsV1'); | ||||
|   static const albumUsersV1 = SyncRequestType._(r'AlbumUsersV1'); | ||||
| 
 | ||||
|   /// List of all possible values in this [enum][SyncRequestType]. | ||||
|   static const values = <SyncRequestType>[ | ||||
| @@ -38,6 +40,8 @@ class SyncRequestType { | ||||
|     assetExifsV1, | ||||
|     partnerAssetsV1, | ||||
|     partnerAssetExifsV1, | ||||
|     albumsV1, | ||||
|     albumUsersV1, | ||||
|   ]; | ||||
| 
 | ||||
|   static SyncRequestType? fromJson(dynamic value) => SyncRequestTypeTypeTransformer().decode(value); | ||||
| @@ -82,6 +86,8 @@ class SyncRequestTypeTypeTransformer { | ||||
|         case r'AssetExifsV1': return SyncRequestType.assetExifsV1; | ||||
|         case r'PartnerAssetsV1': return SyncRequestType.partnerAssetsV1; | ||||
|         case r'PartnerAssetExifsV1': return SyncRequestType.partnerAssetExifsV1; | ||||
|         case r'AlbumsV1': return SyncRequestType.albumsV1; | ||||
|         case r'AlbumUsersV1': return SyncRequestType.albumUsersV1; | ||||
|         default: | ||||
|           if (!allowNull) { | ||||
|             throw ArgumentError('Unknown enum value to decode: $data'); | ||||
|   | ||||
| @@ -12710,6 +12710,105 @@ | ||||
|         ], | ||||
|         "type": "object" | ||||
|       }, | ||||
|       "SyncAlbumDeleteV1": { | ||||
|         "properties": { | ||||
|           "albumId": { | ||||
|             "type": "string" | ||||
|           } | ||||
|         }, | ||||
|         "required": [ | ||||
|           "albumId" | ||||
|         ], | ||||
|         "type": "object" | ||||
|       }, | ||||
|       "SyncAlbumUserDeleteV1": { | ||||
|         "properties": { | ||||
|           "albumId": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "userId": { | ||||
|             "type": "string" | ||||
|           } | ||||
|         }, | ||||
|         "required": [ | ||||
|           "albumId", | ||||
|           "userId" | ||||
|         ], | ||||
|         "type": "object" | ||||
|       }, | ||||
|       "SyncAlbumUserV1": { | ||||
|         "properties": { | ||||
|           "albumId": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "role": { | ||||
|             "enum": [ | ||||
|               "editor", | ||||
|               "viewer" | ||||
|             ], | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "userId": { | ||||
|             "type": "string" | ||||
|           } | ||||
|         }, | ||||
|         "required": [ | ||||
|           "albumId", | ||||
|           "role", | ||||
|           "userId" | ||||
|         ], | ||||
|         "type": "object" | ||||
|       }, | ||||
|       "SyncAlbumV1": { | ||||
|         "properties": { | ||||
|           "createdAt": { | ||||
|             "format": "date-time", | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "description": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "id": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "isActivityEnabled": { | ||||
|             "type": "boolean" | ||||
|           }, | ||||
|           "name": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "order": { | ||||
|             "allOf": [ | ||||
|               { | ||||
|                 "$ref": "#/components/schemas/AssetOrder" | ||||
|               } | ||||
|             ] | ||||
|           }, | ||||
|           "ownerId": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "thumbnailAssetId": { | ||||
|             "nullable": true, | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "updatedAt": { | ||||
|             "format": "date-time", | ||||
|             "type": "string" | ||||
|           } | ||||
|         }, | ||||
|         "required": [ | ||||
|           "createdAt", | ||||
|           "description", | ||||
|           "id", | ||||
|           "isActivityEnabled", | ||||
|           "name", | ||||
|           "order", | ||||
|           "ownerId", | ||||
|           "thumbnailAssetId", | ||||
|           "updatedAt" | ||||
|         ], | ||||
|         "type": "object" | ||||
|       }, | ||||
|       "SyncAssetDeleteV1": { | ||||
|         "properties": { | ||||
|           "assetId": { | ||||
| @@ -12937,7 +13036,11 @@ | ||||
|           "AssetExifV1", | ||||
|           "PartnerAssetV1", | ||||
|           "PartnerAssetDeleteV1", | ||||
|           "PartnerAssetExifV1" | ||||
|           "PartnerAssetExifV1", | ||||
|           "AlbumV1", | ||||
|           "AlbumDeleteV1", | ||||
|           "AlbumUserV1", | ||||
|           "AlbumUserDeleteV1" | ||||
|         ], | ||||
|         "type": "string" | ||||
|       }, | ||||
| @@ -12982,7 +13085,9 @@ | ||||
|           "AssetsV1", | ||||
|           "AssetExifsV1", | ||||
|           "PartnerAssetsV1", | ||||
|           "PartnerAssetExifsV1" | ||||
|           "PartnerAssetExifsV1", | ||||
|           "AlbumsV1", | ||||
|           "AlbumUsersV1" | ||||
|         ], | ||||
|         "type": "string" | ||||
|       }, | ||||
|   | ||||
| @@ -3860,7 +3860,11 @@ export enum SyncEntityType { | ||||
|     AssetExifV1 = "AssetExifV1", | ||||
|     PartnerAssetV1 = "PartnerAssetV1", | ||||
|     PartnerAssetDeleteV1 = "PartnerAssetDeleteV1", | ||||
|     PartnerAssetExifV1 = "PartnerAssetExifV1" | ||||
|     PartnerAssetExifV1 = "PartnerAssetExifV1", | ||||
|     AlbumV1 = "AlbumV1", | ||||
|     AlbumDeleteV1 = "AlbumDeleteV1", | ||||
|     AlbumUserV1 = "AlbumUserV1", | ||||
|     AlbumUserDeleteV1 = "AlbumUserDeleteV1" | ||||
| } | ||||
| export enum SyncRequestType { | ||||
|     UsersV1 = "UsersV1", | ||||
| @@ -3868,7 +3872,9 @@ export enum SyncRequestType { | ||||
|     AssetsV1 = "AssetsV1", | ||||
|     AssetExifsV1 = "AssetExifsV1", | ||||
|     PartnerAssetsV1 = "PartnerAssetsV1", | ||||
|     PartnerAssetExifsV1 = "PartnerAssetExifsV1" | ||||
|     PartnerAssetExifsV1 = "PartnerAssetExifsV1", | ||||
|     AlbumsV1 = "AlbumsV1", | ||||
|     AlbumUsersV1 = "AlbumUsersV1" | ||||
| } | ||||
| export enum TranscodeHWAccel { | ||||
|     Nvenc = "nvenc", | ||||
|   | ||||
| @@ -23,6 +23,7 @@ | ||||
|     "test:medium": "vitest --config test/vitest.config.medium.mjs", | ||||
|     "typeorm": "typeorm", | ||||
|     "lifecycle": "node ./dist/utils/lifecycle.js", | ||||
|     "migrations:debug": "node ./dist/bin/migrations.js debug", | ||||
|     "migrations:generate": "node ./dist/bin/migrations.js generate", | ||||
|     "migrations:create": "node ./dist/bin/migrations.js create", | ||||
|     "migrations:run": "node ./dist/bin/migrations.js run", | ||||
|   | ||||
| @@ -125,6 +125,7 @@ const compare = async () => { | ||||
|   const down = schemaDiff(target, source, { | ||||
|     tables: { ignoreExtra: false }, | ||||
|     functions: { ignoreExtra: false }, | ||||
|     extension: { ignoreMissing: true }, | ||||
|   }); | ||||
|  | ||||
|   return { up, down }; | ||||
|   | ||||
							
								
								
									
										18
									
								
								server/src/db.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										18
									
								
								server/src/db.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -74,6 +74,20 @@ export interface Albums { | ||||
|   updateId: Generated<string>; | ||||
| } | ||||
|  | ||||
| export interface AlbumsAudit { | ||||
|   deletedAt: Generated<Timestamp>; | ||||
|   id: Generated<string>; | ||||
|   albumId: string; | ||||
|   userId: string; | ||||
| } | ||||
|  | ||||
| export interface AlbumUsersAudit { | ||||
|   deletedAt: Generated<Timestamp>; | ||||
|   id: Generated<string>; | ||||
|   albumId: string; | ||||
|   userId: string; | ||||
| } | ||||
|  | ||||
| export interface AlbumsAssetsAssets { | ||||
|   albumsId: string; | ||||
|   assetsId: string; | ||||
| @@ -84,6 +98,8 @@ export interface AlbumsSharedUsersUsers { | ||||
|   albumsId: string; | ||||
|   role: Generated<AlbumUserRole>; | ||||
|   usersId: string; | ||||
|   updatedAt: Generated<Timestamp>; | ||||
|   updateId: Generated<string>; | ||||
| } | ||||
|  | ||||
| export interface ApiKeys { | ||||
| @@ -466,8 +482,10 @@ export interface VersionHistory { | ||||
| export interface DB { | ||||
|   activity: Activity; | ||||
|   albums: Albums; | ||||
|   albums_audit: AlbumsAudit; | ||||
|   albums_assets_assets: AlbumsAssetsAssets; | ||||
|   albums_shared_users_users: AlbumsSharedUsersUsers; | ||||
|   album_users_audit: AlbumUsersAudit; | ||||
|   api_keys: ApiKeys; | ||||
|   asset_faces: AssetFaces; | ||||
|   asset_files: AssetFiles; | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { ApiProperty } from '@nestjs/swagger'; | ||||
| import { IsEnum, IsInt, IsPositive, IsString } from 'class-validator'; | ||||
| import { AssetResponseDto } from 'src/dtos/asset-response.dto'; | ||||
| import { AssetType, AssetVisibility, SyncEntityType, SyncRequestType } from 'src/enum'; | ||||
| import { AlbumUserRole, AssetOrder, AssetType, AssetVisibility, SyncEntityType, SyncRequestType } from 'src/enum'; | ||||
| import { Optional, ValidateDate, ValidateUUID } from 'src/validation'; | ||||
|  | ||||
| export class AssetFullSyncDto { | ||||
| @@ -112,6 +112,34 @@ export class SyncAssetExifV1 { | ||||
|   fps!: number | null; | ||||
| } | ||||
|  | ||||
| export class SyncAlbumDeleteV1 { | ||||
|   albumId!: string; | ||||
| } | ||||
|  | ||||
| export class SyncAlbumUserDeleteV1 { | ||||
|   albumId!: string; | ||||
|   userId!: string; | ||||
| } | ||||
|  | ||||
| export class SyncAlbumUserV1 { | ||||
|   albumId!: string; | ||||
|   userId!: string; | ||||
|   role!: AlbumUserRole; | ||||
| } | ||||
|  | ||||
| export class SyncAlbumV1 { | ||||
|   id!: string; | ||||
|   ownerId!: string; | ||||
|   name!: string; | ||||
|   description!: string; | ||||
|   createdAt!: Date; | ||||
|   updatedAt!: Date; | ||||
|   thumbnailAssetId!: string | null; | ||||
|   isActivityEnabled!: boolean; | ||||
|   @ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder }) | ||||
|   order!: AssetOrder; | ||||
| } | ||||
|  | ||||
| export type SyncItem = { | ||||
|   [SyncEntityType.UserV1]: SyncUserV1; | ||||
|   [SyncEntityType.UserDeleteV1]: SyncUserDeleteV1; | ||||
| @@ -123,10 +151,13 @@ export type SyncItem = { | ||||
|   [SyncEntityType.PartnerAssetV1]: SyncAssetV1; | ||||
|   [SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1; | ||||
|   [SyncEntityType.PartnerAssetExifV1]: SyncAssetExifV1; | ||||
|   [SyncEntityType.AlbumV1]: SyncAlbumV1; | ||||
|   [SyncEntityType.AlbumDeleteV1]: SyncAlbumDeleteV1; | ||||
|   [SyncEntityType.AlbumUserV1]: SyncAlbumUserV1; | ||||
|   [SyncEntityType.AlbumUserDeleteV1]: SyncAlbumUserDeleteV1; | ||||
| }; | ||||
|  | ||||
| const responseDtos = [ | ||||
|   // | ||||
|   SyncUserV1, | ||||
|   SyncUserDeleteV1, | ||||
|   SyncPartnerV1, | ||||
| @@ -134,6 +165,10 @@ const responseDtos = [ | ||||
|   SyncAssetV1, | ||||
|   SyncAssetDeleteV1, | ||||
|   SyncAssetExifV1, | ||||
|   SyncAlbumV1, | ||||
|   SyncAlbumDeleteV1, | ||||
|   SyncAlbumUserV1, | ||||
|   SyncAlbumUserDeleteV1, | ||||
| ]; | ||||
|  | ||||
| export const extraSyncModels = responseDtos; | ||||
|   | ||||
| @@ -578,6 +578,8 @@ export enum SyncRequestType { | ||||
|   AssetExifsV1 = 'AssetExifsV1', | ||||
|   PartnerAssetsV1 = 'PartnerAssetsV1', | ||||
|   PartnerAssetExifsV1 = 'PartnerAssetExifsV1', | ||||
|   AlbumsV1 = 'AlbumsV1', | ||||
|   AlbumUsersV1 = 'AlbumUsersV1', | ||||
| } | ||||
|  | ||||
| export enum SyncEntityType { | ||||
| @@ -594,6 +596,11 @@ export enum SyncEntityType { | ||||
|   PartnerAssetV1 = 'PartnerAssetV1', | ||||
|   PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1', | ||||
|   PartnerAssetExifV1 = 'PartnerAssetExifV1', | ||||
|  | ||||
|   AlbumV1 = 'AlbumV1', | ||||
|   AlbumDeleteV1 = 'AlbumDeleteV1', | ||||
|   AlbumUserV1 = 'AlbumUserV1', | ||||
|   AlbumUserDeleteV1 = 'AlbumUserDeleteV1', | ||||
| } | ||||
|  | ||||
| export enum NotificationLevel { | ||||
|   | ||||
| @@ -6,7 +6,9 @@ insert into | ||||
| values | ||||
|   ($1, $2) | ||||
| returning | ||||
|   * | ||||
|   "usersId", | ||||
|   "albumsId", | ||||
|   "role" | ||||
|  | ||||
| -- AlbumUserRepository.update | ||||
| update "albums_shared_users_users" | ||||
|   | ||||
| @@ -246,3 +246,98 @@ where | ||||
|   and "updatedAt" < now() - interval '1 millisecond' | ||||
| order by | ||||
|   "updateId" asc | ||||
|  | ||||
| -- SyncRepository.getAlbumDeletes | ||||
| select | ||||
|   "id", | ||||
|   "albumId" | ||||
| from | ||||
|   "albums_audit" | ||||
| where | ||||
|   "userId" = $1 | ||||
|   and "deletedAt" < now() - interval '1 millisecond' | ||||
| order by | ||||
|   "id" asc | ||||
|  | ||||
| -- SyncRepository.getAlbumUpserts | ||||
| select distinct | ||||
|   on ("albums"."id", "albums"."updateId") "albums"."id", | ||||
|   "albums"."ownerId", | ||||
|   "albums"."albumName" as "name", | ||||
|   "albums"."description", | ||||
|   "albums"."createdAt", | ||||
|   "albums"."updatedAt", | ||||
|   "albums"."albumThumbnailAssetId" as "thumbnailAssetId", | ||||
|   "albums"."isActivityEnabled", | ||||
|   "albums"."order", | ||||
|   "albums"."updateId" | ||||
| from | ||||
|   "albums" | ||||
|   left join "albums_shared_users_users" as "album_users" on "albums"."id" = "album_users"."albumsId" | ||||
| where | ||||
|   "albums"."updatedAt" < now() - interval '1 millisecond' | ||||
|   and ( | ||||
|     "albums"."ownerId" = $1 | ||||
|     or "album_users"."usersId" = $2 | ||||
|   ) | ||||
| order by | ||||
|   "albums"."updateId" asc | ||||
|  | ||||
| -- SyncRepository.getAlbumUserDeletes | ||||
| select | ||||
|   "id", | ||||
|   "userId", | ||||
|   "albumId" | ||||
| from | ||||
|   "album_users_audit" | ||||
| where | ||||
|   "albumId" in ( | ||||
|     select | ||||
|       "id" | ||||
|     from | ||||
|       "albums" | ||||
|     where | ||||
|       "ownerId" = $1 | ||||
|     union | ||||
|     ( | ||||
|       select | ||||
|         "albumUsers"."albumsId" as "id" | ||||
|       from | ||||
|         "albums_shared_users_users" as "albumUsers" | ||||
|       where | ||||
|         "albumUsers"."usersId" = $2 | ||||
|     ) | ||||
|   ) | ||||
|   and "deletedAt" < now() - interval '1 millisecond' | ||||
| order by | ||||
|   "id" asc | ||||
|  | ||||
| -- SyncRepository.getAlbumUserUpserts | ||||
| select | ||||
|   "albums_shared_users_users"."albumsId" as "albumId", | ||||
|   "albums_shared_users_users"."usersId" as "userId", | ||||
|   "albums_shared_users_users"."role", | ||||
|   "albums_shared_users_users"."updateId" | ||||
| from | ||||
|   "albums_shared_users_users" | ||||
| where | ||||
|   "albums_shared_users_users"."updatedAt" < now() - interval '1 millisecond' | ||||
|   and "albums_shared_users_users"."albumsId" in ( | ||||
|     select | ||||
|       "id" | ||||
|     from | ||||
|       "albums" | ||||
|     where | ||||
|       "ownerId" = $1 | ||||
|     union | ||||
|     ( | ||||
|       select | ||||
|         "albumUsers"."albumsId" as "id" | ||||
|       from | ||||
|         "albums_shared_users_users" as "albumUsers" | ||||
|       where | ||||
|         "albumUsers"."usersId" = $2 | ||||
|     ) | ||||
|   ) | ||||
| order by | ||||
|   "albums_shared_users_users"."updateId" asc | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import { Insertable, Kysely, Selectable, Updateable } from 'kysely'; | ||||
| import { Insertable, Kysely, Updateable } from 'kysely'; | ||||
| import { InjectKysely } from 'nestjs-kysely'; | ||||
| import { AlbumsSharedUsersUsers, DB } from 'src/db'; | ||||
| import { DummyValue, GenerateSql } from 'src/decorators'; | ||||
| @@ -15,8 +15,12 @@ export class AlbumUserRepository { | ||||
|   constructor(@InjectKysely() private db: Kysely<DB>) {} | ||||
|  | ||||
|   @GenerateSql({ params: [{ usersId: DummyValue.UUID, albumsId: DummyValue.UUID }] }) | ||||
|   create(albumUser: Insertable<AlbumsSharedUsersUsers>): Promise<Selectable<AlbumsSharedUsersUsers>> { | ||||
|     return this.db.insertInto('albums_shared_users_users').values(albumUser).returningAll().executeTakeFirstOrThrow(); | ||||
|   create(albumUser: Insertable<AlbumsSharedUsersUsers>) { | ||||
|     return this.db | ||||
|       .insertInto('albums_shared_users_users') | ||||
|       .values(albumUser) | ||||
|       .returning(['usersId', 'albumsId', 'role']) | ||||
|       .executeTakeFirstOrThrow(); | ||||
|   } | ||||
|  | ||||
|   @GenerateSql({ params: [{ usersId: DummyValue.UUID, albumsId: DummyValue.UUID }, { role: AlbumUserRole.VIEWER }] }) | ||||
|   | ||||
| @@ -7,8 +7,8 @@ import { DummyValue, GenerateSql } from 'src/decorators'; | ||||
| import { SyncEntityType } from 'src/enum'; | ||||
| import { SyncAck } from 'src/types'; | ||||
|  | ||||
| type auditTables = 'users_audit' | 'partners_audit' | 'assets_audit'; | ||||
| type upsertTables = 'users' | 'partners' | 'assets' | 'exif'; | ||||
| type AuditTables = 'users_audit' | 'partners_audit' | 'assets_audit' | 'albums_audit' | 'album_users_audit'; | ||||
| type UpsertTables = 'users' | 'partners' | 'assets' | 'exif' | 'albums' | 'albums_shared_users_users'; | ||||
|  | ||||
| @Injectable() | ||||
| export class SyncRepository { | ||||
| @@ -110,7 +110,6 @@ export class SyncRepository { | ||||
|       .selectFrom('assets_audit') | ||||
|       .select(['id', 'assetId']) | ||||
|       .where('ownerId', '=', userId) | ||||
|       .$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId)) | ||||
|       .$call((qb) => this.auditTableFilters(qb, ack)) | ||||
|       .stream(); | ||||
|   } | ||||
| @@ -154,19 +153,115 @@ export class SyncRepository { | ||||
|       .stream(); | ||||
|   } | ||||
|  | ||||
|   private auditTableFilters<T extends keyof Pick<DB, auditTables>, D>(qb: SelectQueryBuilder<DB, T, D>, ack?: SyncAck) { | ||||
|     const builder = qb as SelectQueryBuilder<DB, auditTables, D>; | ||||
|   @GenerateSql({ params: [DummyValue.UUID], stream: true }) | ||||
|   getAlbumDeletes(userId: string, ack?: SyncAck) { | ||||
|     return this.db | ||||
|       .selectFrom('albums_audit') | ||||
|       .select(['id', 'albumId']) | ||||
|       .where('userId', '=', userId) | ||||
|       .$call((qb) => this.auditTableFilters(qb, ack)) | ||||
|       .stream(); | ||||
|   } | ||||
|  | ||||
|   @GenerateSql({ params: [DummyValue.UUID], stream: true }) | ||||
|   getAlbumUpserts(userId: string, ack?: SyncAck) { | ||||
|     return this.db | ||||
|       .selectFrom('albums') | ||||
|       .distinctOn(['albums.id', 'albums.updateId']) | ||||
|       .where('albums.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'")) | ||||
|       .$if(!!ack, (qb) => qb.where('albums.updateId', '>', ack!.updateId)) | ||||
|       .orderBy('albums.updateId', 'asc') | ||||
|       .leftJoin('albums_shared_users_users as album_users', 'albums.id', 'album_users.albumsId') | ||||
|       .where((eb) => eb.or([eb('albums.ownerId', '=', userId), eb('album_users.usersId', '=', userId)])) | ||||
|       .select([ | ||||
|         'albums.id', | ||||
|         'albums.ownerId', | ||||
|         'albums.albumName as name', | ||||
|         'albums.description', | ||||
|         'albums.createdAt', | ||||
|         'albums.updatedAt', | ||||
|         'albums.albumThumbnailAssetId as thumbnailAssetId', | ||||
|         'albums.isActivityEnabled', | ||||
|         'albums.order', | ||||
|         'albums.updateId', | ||||
|       ]) | ||||
|       .stream(); | ||||
|   } | ||||
|  | ||||
|   @GenerateSql({ params: [DummyValue.UUID], stream: true }) | ||||
|   getAlbumUserDeletes(userId: string, ack?: SyncAck) { | ||||
|     return this.db | ||||
|       .selectFrom('album_users_audit') | ||||
|       .select(['id', 'userId', 'albumId']) | ||||
|       .where((eb) => | ||||
|         eb( | ||||
|           'albumId', | ||||
|           'in', | ||||
|           eb | ||||
|             .selectFrom('albums') | ||||
|             .select(['id']) | ||||
|             .where('ownerId', '=', userId) | ||||
|             .union((eb) => | ||||
|               eb.parens( | ||||
|                 eb | ||||
|                   .selectFrom('albums_shared_users_users as albumUsers') | ||||
|                   .select(['albumUsers.albumsId as id']) | ||||
|                   .where('albumUsers.usersId', '=', userId), | ||||
|               ), | ||||
|             ), | ||||
|         ), | ||||
|       ) | ||||
|       .$call((qb) => this.auditTableFilters(qb, ack)) | ||||
|       .stream(); | ||||
|   } | ||||
|  | ||||
|   @GenerateSql({ params: [DummyValue.UUID], stream: true }) | ||||
|   getAlbumUserUpserts(userId: string, ack?: SyncAck) { | ||||
|     return this.db | ||||
|       .selectFrom('albums_shared_users_users') | ||||
|       .select([ | ||||
|         'albums_shared_users_users.albumsId as albumId', | ||||
|         'albums_shared_users_users.usersId as userId', | ||||
|         'albums_shared_users_users.role', | ||||
|         'albums_shared_users_users.updateId', | ||||
|       ]) | ||||
|       .where('albums_shared_users_users.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'")) | ||||
|       .$if(!!ack, (qb) => qb.where('albums_shared_users_users.updateId', '>', ack!.updateId)) | ||||
|       .orderBy('albums_shared_users_users.updateId', 'asc') | ||||
|       .where((eb) => | ||||
|         eb( | ||||
|           'albums_shared_users_users.albumsId', | ||||
|           'in', | ||||
|           eb | ||||
|             .selectFrom('albums') | ||||
|             .select(['id']) | ||||
|             .where('ownerId', '=', userId) | ||||
|             .union((eb) => | ||||
|               eb.parens( | ||||
|                 eb | ||||
|                   .selectFrom('albums_shared_users_users as albumUsers') | ||||
|                   .select(['albumUsers.albumsId as id']) | ||||
|                   .where('albumUsers.usersId', '=', userId), | ||||
|               ), | ||||
|             ), | ||||
|         ), | ||||
|       ) | ||||
|       .stream(); | ||||
|   } | ||||
|  | ||||
|   private auditTableFilters<T extends keyof Pick<DB, AuditTables>, D>(qb: SelectQueryBuilder<DB, T, D>, ack?: SyncAck) { | ||||
|     const builder = qb as SelectQueryBuilder<DB, AuditTables, D>; | ||||
|     return builder | ||||
|       .where('deletedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'")) | ||||
|       .$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId)) | ||||
|       .orderBy('id', 'asc') as SelectQueryBuilder<DB, T, D>; | ||||
|   } | ||||
|  | ||||
|   private upsertTableFilters<T extends keyof Pick<DB, upsertTables>, D>( | ||||
|   private upsertTableFilters<T extends keyof Pick<DB, UpsertTables>, D>( | ||||
|     qb: SelectQueryBuilder<DB, T, D>, | ||||
|     ack?: SyncAck, | ||||
|   ) { | ||||
|     const builder = qb as SelectQueryBuilder<DB, upsertTables, D>; | ||||
|     const builder = qb as SelectQueryBuilder<DB, UpsertTables, D>; | ||||
|     return builder | ||||
|       .where('updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'")) | ||||
|       .$if(!!ack, (qb) => qb.where('updateId', '>', ack!.updateId)) | ||||
|   | ||||
| @@ -23,6 +23,19 @@ export const immich_uuid_v7 = registerFunction({ | ||||
|   synchronize: false, | ||||
| }); | ||||
|  | ||||
| export const album_user_after_insert = registerFunction({ | ||||
|   name: 'album_user_after_insert', | ||||
|   returnType: 'TRIGGER', | ||||
|   language: 'PLPGSQL', | ||||
|   body: ` | ||||
|     BEGIN | ||||
|       UPDATE albums SET "updatedAt" = clock_timestamp(), "updateId" = immich_uuid_v7(clock_timestamp()) | ||||
|       WHERE "id" IN (SELECT DISTINCT "albumsId" FROM inserted_rows); | ||||
|       RETURN NULL; | ||||
|     END`, | ||||
|   synchronize: false, | ||||
| }); | ||||
|  | ||||
| export const updated_at = registerFunction({ | ||||
|   name: 'updated_at', | ||||
|   returnType: 'TRIGGER', | ||||
| @@ -114,3 +127,38 @@ export const assets_delete_audit = registerFunction({ | ||||
|     END`, | ||||
|   synchronize: false, | ||||
| }); | ||||
|  | ||||
| export const albums_delete_audit = registerFunction({ | ||||
|   name: 'albums_delete_audit', | ||||
|   returnType: 'TRIGGER', | ||||
|   language: 'PLPGSQL', | ||||
|   body: ` | ||||
|     BEGIN | ||||
|       INSERT INTO albums_audit ("albumId", "userId") | ||||
|       SELECT "id", "ownerId" | ||||
|       FROM OLD; | ||||
|       RETURN NULL; | ||||
|     END`, | ||||
|   synchronize: false, | ||||
| }); | ||||
|  | ||||
| export const album_users_delete_audit = registerFunction({ | ||||
|   name: 'album_users_delete_audit', | ||||
|   returnType: 'TRIGGER', | ||||
|   language: 'PLPGSQL', | ||||
|   body: ` | ||||
|     BEGIN | ||||
|       INSERT INTO albums_audit ("albumId", "userId") | ||||
|       SELECT "albumsId", "usersId" | ||||
|       FROM OLD; | ||||
|  | ||||
|       IF pg_trigger_depth() = 1 THEN | ||||
|         INSERT INTO album_users_audit ("albumId", "userId") | ||||
|         SELECT "albumsId", "usersId" | ||||
|         FROM OLD; | ||||
|       END IF; | ||||
|  | ||||
|       RETURN NULL; | ||||
|     END`, | ||||
|   synchronize: false, | ||||
| }); | ||||
|   | ||||
| @@ -1,5 +1,8 @@ | ||||
| import { asset_face_source_type, asset_visibility_enum, assets_status_enum } from 'src/schema/enums'; | ||||
| import { | ||||
|   album_user_after_insert, | ||||
|   album_users_delete_audit, | ||||
|   albums_delete_audit, | ||||
|   assets_delete_audit, | ||||
|   f_concat_ws, | ||||
|   f_unaccent, | ||||
| @@ -11,6 +14,8 @@ import { | ||||
| } from 'src/schema/functions'; | ||||
| import { ActivityTable } from 'src/schema/tables/activity.table'; | ||||
| import { AlbumAssetTable } from 'src/schema/tables/album-asset.table'; | ||||
| import { AlbumAuditTable } from 'src/schema/tables/album-audit.table'; | ||||
| import { AlbumUserAuditTable } from 'src/schema/tables/album-user-audit.table'; | ||||
| import { AlbumUserTable } from 'src/schema/tables/album-user.table'; | ||||
| import { AlbumTable } from 'src/schema/tables/album.table'; | ||||
| import { APIKeyTable } from 'src/schema/tables/api-key.table'; | ||||
| @@ -45,15 +50,16 @@ import { UserAuditTable } from 'src/schema/tables/user-audit.table'; | ||||
| import { UserMetadataTable } from 'src/schema/tables/user-metadata.table'; | ||||
| import { UserTable } from 'src/schema/tables/user.table'; | ||||
| import { VersionHistoryTable } from 'src/schema/tables/version-history.table'; | ||||
| import { ConfigurationParameter, Database, Extensions } from 'src/sql-tools'; | ||||
| import { Database, Extensions } from 'src/sql-tools'; | ||||
|  | ||||
| @Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql']) | ||||
| @ConfigurationParameter({ name: 'search_path', value: () => '"$user", public, vectors', scope: 'database' }) | ||||
| @Database({ name: 'immich' }) | ||||
| export class ImmichDatabase { | ||||
|   tables = [ | ||||
|     ActivityTable, | ||||
|     AlbumAssetTable, | ||||
|     AlbumAuditTable, | ||||
|     AlbumUserAuditTable, | ||||
|     AlbumUserTable, | ||||
|     AlbumTable, | ||||
|     APIKeyTable, | ||||
| @@ -99,6 +105,9 @@ export class ImmichDatabase { | ||||
|     users_delete_audit, | ||||
|     partners_delete_audit, | ||||
|     assets_delete_audit, | ||||
|     albums_delete_audit, | ||||
|     album_user_after_insert, | ||||
|     album_users_delete_audit, | ||||
|   ]; | ||||
|  | ||||
|   enum = [assets_status_enum, asset_face_source_type, asset_visibility_enum]; | ||||
|   | ||||
| @@ -0,0 +1,96 @@ | ||||
| import { Kysely, sql } from 'kysely'; | ||||
|  | ||||
| export async function up(db: Kysely<any>): Promise<void> { | ||||
|   await sql`CREATE OR REPLACE FUNCTION album_user_after_insert() | ||||
|   RETURNS TRIGGER | ||||
|   LANGUAGE PLPGSQL | ||||
|   AS $$ | ||||
|     BEGIN | ||||
|       UPDATE albums SET "updatedAt" = clock_timestamp(), "updateId" = immich_uuid_v7(clock_timestamp()) | ||||
|       WHERE "id" IN (SELECT DISTINCT "albumsId" FROM inserted_rows); | ||||
|       RETURN NULL; | ||||
|     END | ||||
|   $$;`.execute(db); | ||||
|   await sql`CREATE OR REPLACE FUNCTION albums_delete_audit() | ||||
|   RETURNS TRIGGER | ||||
|   LANGUAGE PLPGSQL | ||||
|   AS $$ | ||||
|     BEGIN | ||||
|       INSERT INTO albums_audit ("albumId", "userId") | ||||
|       SELECT "id", "ownerId" | ||||
|       FROM OLD; | ||||
|       RETURN NULL; | ||||
|     END | ||||
|   $$;`.execute(db); | ||||
|   await sql`CREATE OR REPLACE FUNCTION album_users_delete_audit() | ||||
|   RETURNS TRIGGER | ||||
|   LANGUAGE PLPGSQL | ||||
|   AS $$ | ||||
|     BEGIN | ||||
|       INSERT INTO albums_audit ("albumId", "userId") | ||||
|       SELECT "albumsId", "usersId" | ||||
|       FROM OLD; | ||||
|  | ||||
|       IF pg_trigger_depth() = 1 THEN | ||||
|         INSERT INTO album_users_audit ("albumId", "userId") | ||||
|         SELECT "albumsId", "usersId" | ||||
|         FROM OLD; | ||||
|       END IF; | ||||
|  | ||||
|       RETURN NULL; | ||||
|     END | ||||
|   $$;`.execute(db); | ||||
|   await sql`CREATE TABLE "albums_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "albumId" uuid NOT NULL, "userId" uuid NOT NULL, "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp());`.execute(db); | ||||
|   await sql`CREATE TABLE "album_users_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "albumId" uuid NOT NULL, "userId" uuid NOT NULL, "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp());`.execute(db); | ||||
|   await sql`ALTER TABLE "albums_audit" ADD CONSTRAINT "PK_c75efea8d4dce316ad29b851a8b" PRIMARY KEY ("id");`.execute(db); | ||||
|   await sql`ALTER TABLE "album_users_audit" ADD CONSTRAINT "PK_f479a2e575b7ebc9698362c1688" PRIMARY KEY ("id");`.execute(db); | ||||
|   await sql`ALTER TABLE "albums_shared_users_users" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db); | ||||
|   await sql`ALTER TABLE "albums_shared_users_users" ADD "updatedAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db); | ||||
|   await sql`CREATE INDEX "IDX_album_users_update_id" ON "albums_shared_users_users" ("updateId")`.execute(db); | ||||
|   await sql`CREATE INDEX "IDX_albums_audit_album_id" ON "albums_audit" ("albumId")`.execute(db); | ||||
|   await sql`CREATE INDEX "IDX_albums_audit_user_id" ON "albums_audit" ("userId")`.execute(db); | ||||
|   await sql`CREATE INDEX "IDX_albums_audit_deleted_at" ON "albums_audit" ("deletedAt")`.execute(db); | ||||
|   await sql`CREATE INDEX "IDX_album_users_audit_album_id" ON "album_users_audit" ("albumId")`.execute(db); | ||||
|   await sql`CREATE INDEX "IDX_album_users_audit_user_id" ON "album_users_audit" ("userId")`.execute(db); | ||||
|   await sql`CREATE INDEX "IDX_album_users_audit_deleted_at" ON "album_users_audit" ("deletedAt")`.execute(db); | ||||
|   await sql`CREATE OR REPLACE TRIGGER "albums_delete_audit" | ||||
|   AFTER DELETE ON "albums" | ||||
|   REFERENCING OLD TABLE AS "old" | ||||
|   FOR EACH STATEMENT | ||||
|   WHEN (pg_trigger_depth() = 0) | ||||
|   EXECUTE FUNCTION albums_delete_audit();`.execute(db); | ||||
|   await sql`CREATE OR REPLACE TRIGGER "album_users_delete_audit" | ||||
|   AFTER DELETE ON "albums_shared_users_users" | ||||
|   REFERENCING OLD TABLE AS "old" | ||||
|   FOR EACH STATEMENT | ||||
|   WHEN (pg_trigger_depth() <= 1) | ||||
|   EXECUTE FUNCTION album_users_delete_audit();`.execute(db); | ||||
|   await sql`CREATE OR REPLACE TRIGGER "album_user_after_insert" | ||||
|   AFTER INSERT ON "albums_shared_users_users" | ||||
|   REFERENCING NEW TABLE AS "inserted_rows" | ||||
|   FOR EACH STATEMENT | ||||
|   EXECUTE FUNCTION album_user_after_insert();`.execute(db); | ||||
|   await sql`CREATE OR REPLACE TRIGGER "album_users_updated_at" | ||||
|   BEFORE UPDATE ON "albums_shared_users_users" | ||||
|   FOR EACH ROW | ||||
|   EXECUTE FUNCTION updated_at();`.execute(db); | ||||
| } | ||||
|  | ||||
| export async function down(db: Kysely<any>): Promise<void> { | ||||
|   await sql`DROP TRIGGER "albums_delete_audit" ON "albums";`.execute(db); | ||||
|   await sql`DROP TRIGGER "album_users_delete_audit" ON "albums_shared_users_users";`.execute(db); | ||||
|   await sql`DROP TRIGGER "album_user_after_insert" ON "albums_shared_users_users";`.execute(db); | ||||
|   await sql`DROP INDEX "IDX_albums_audit_album_id";`.execute(db); | ||||
|   await sql`DROP INDEX "IDX_albums_audit_user_id";`.execute(db); | ||||
|   await sql`DROP INDEX "IDX_albums_audit_deleted_at";`.execute(db); | ||||
|   await sql`DROP INDEX "IDX_album_users_audit_album_id";`.execute(db); | ||||
|   await sql`DROP INDEX "IDX_album_users_audit_user_id";`.execute(db); | ||||
|   await sql`DROP INDEX "IDX_album_users_audit_deleted_at";`.execute(db); | ||||
|   await sql`ALTER TABLE "albums_audit" DROP CONSTRAINT "PK_c75efea8d4dce316ad29b851a8b";`.execute(db); | ||||
|   await sql`ALTER TABLE "album_users_audit" DROP CONSTRAINT "PK_f479a2e575b7ebc9698362c1688";`.execute(db); | ||||
|   await sql`DROP TABLE "albums_audit";`.execute(db); | ||||
|   await sql`DROP TABLE "album_users_audit";`.execute(db); | ||||
|   await sql`DROP FUNCTION album_user_after_insert;`.execute(db); | ||||
|   await sql`DROP FUNCTION albums_delete_audit;`.execute(db); | ||||
|   await sql`DROP FUNCTION album_users_delete_audit;`.execute(db); | ||||
| } | ||||
							
								
								
									
										17
									
								
								server/src/schema/tables/album-audit.table.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								server/src/schema/tables/album-audit.table.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; | ||||
| import { Column, CreateDateColumn, Table } from 'src/sql-tools'; | ||||
|  | ||||
| @Table('albums_audit') | ||||
| export class AlbumAuditTable { | ||||
|   @PrimaryGeneratedUuidV7Column() | ||||
|   id!: string; | ||||
|  | ||||
|   @Column({ type: 'uuid', indexName: 'IDX_albums_audit_album_id' }) | ||||
|   albumId!: string; | ||||
|  | ||||
|   @Column({ type: 'uuid', indexName: 'IDX_albums_audit_user_id' }) | ||||
|   userId!: string; | ||||
|  | ||||
|   @CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_albums_audit_deleted_at' }) | ||||
|   deletedAt!: Date; | ||||
| } | ||||
							
								
								
									
										17
									
								
								server/src/schema/tables/album-user-audit.table.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								server/src/schema/tables/album-user-audit.table.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; | ||||
| import { Column, CreateDateColumn, Table } from 'src/sql-tools'; | ||||
|  | ||||
| @Table('album_users_audit') | ||||
| export class AlbumUserAuditTable { | ||||
|   @PrimaryGeneratedUuidV7Column() | ||||
|   id!: string; | ||||
|  | ||||
|   @Column({ type: 'uuid', indexName: 'IDX_album_users_audit_album_id' }) | ||||
|   albumId!: string; | ||||
|  | ||||
|   @Column({ type: 'uuid', indexName: 'IDX_album_users_audit_user_id' }) | ||||
|   userId!: string; | ||||
|  | ||||
|   @CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_album_users_audit_deleted_at' }) | ||||
|   deletedAt!: Date; | ||||
| } | ||||
| @@ -1,12 +1,36 @@ | ||||
| import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; | ||||
| import { AlbumUserRole } from 'src/enum'; | ||||
| import { album_user_after_insert, album_users_delete_audit } from 'src/schema/functions'; | ||||
| import { AlbumTable } from 'src/schema/tables/album.table'; | ||||
| import { UserTable } from 'src/schema/tables/user.table'; | ||||
| import { Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools'; | ||||
| import { | ||||
|   AfterDeleteTrigger, | ||||
|   AfterInsertTrigger, | ||||
|   Column, | ||||
|   ForeignKeyColumn, | ||||
|   Index, | ||||
|   Table, | ||||
|   UpdateDateColumn, | ||||
| } from 'src/sql-tools'; | ||||
|  | ||||
| @Table({ name: 'albums_shared_users_users', primaryConstraintName: 'PK_7df55657e0b2e8b626330a0ebc8' }) | ||||
| // Pre-existing indices from original album <--> user ManyToMany mapping | ||||
| @Index({ name: 'IDX_427c350ad49bd3935a50baab73', columns: ['albumsId'] }) | ||||
| @Index({ name: 'IDX_f48513bf9bccefd6ff3ad30bd0', columns: ['usersId'] }) | ||||
| @UpdatedAtTrigger('album_users_updated_at') | ||||
| @AfterInsertTrigger({ | ||||
|   name: 'album_user_after_insert', | ||||
|   scope: 'statement', | ||||
|   referencingNewTableAs: 'inserted_rows', | ||||
|   function: album_user_after_insert, | ||||
| }) | ||||
| @AfterDeleteTrigger({ | ||||
|   name: 'album_users_delete_audit', | ||||
|   scope: 'statement', | ||||
|   function: album_users_delete_audit, | ||||
|   referencingOldTableAs: 'old', | ||||
|   when: 'pg_trigger_depth() <= 1', | ||||
| }) | ||||
| export class AlbumUserTable { | ||||
|   @ForeignKeyColumn(() => AlbumTable, { | ||||
|     onDelete: 'CASCADE', | ||||
| @@ -26,4 +50,10 @@ export class AlbumUserTable { | ||||
|  | ||||
|   @Column({ type: 'character varying', default: AlbumUserRole.EDITOR }) | ||||
|   role!: AlbumUserRole; | ||||
|  | ||||
|   @UpdateIdColumn({ indexName: 'IDX_album_users_update_id' }) | ||||
|   updateId?: string; | ||||
|  | ||||
|   @UpdateDateColumn() | ||||
|   updatedAt!: Date; | ||||
| } | ||||
|   | ||||
| @@ -1,8 +1,10 @@ | ||||
| import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; | ||||
| import { AssetOrder } from 'src/enum'; | ||||
| import { albums_delete_audit } from 'src/schema/functions'; | ||||
| import { AssetTable } from 'src/schema/tables/asset.table'; | ||||
| import { UserTable } from 'src/schema/tables/user.table'; | ||||
| import { | ||||
|   AfterDeleteTrigger, | ||||
|   Column, | ||||
|   CreateDateColumn, | ||||
|   DeleteDateColumn, | ||||
| @@ -14,6 +16,13 @@ import { | ||||
|  | ||||
| @Table({ name: 'albums', primaryConstraintName: 'PK_7f71c7b5bc7c87b8f94c9a93a00' }) | ||||
| @UpdatedAtTrigger('albums_updated_at') | ||||
| @AfterDeleteTrigger({ | ||||
|   name: 'albums_delete_audit', | ||||
|   scope: 'statement', | ||||
|   function: albums_delete_audit, | ||||
|   referencingOldTableAs: 'old', | ||||
|   when: 'pg_trigger_depth() = 0', | ||||
| }) | ||||
| export class AlbumTable { | ||||
|   @PrimaryGeneratedColumn() | ||||
|   id!: string; | ||||
|   | ||||
| @@ -24,13 +24,14 @@ import { fromAck, serialize } from 'src/utils/sync'; | ||||
|  | ||||
| const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] }; | ||||
| export const SYNC_TYPES_ORDER = [ | ||||
|   // | ||||
|   SyncRequestType.UsersV1, | ||||
|   SyncRequestType.PartnersV1, | ||||
|   SyncRequestType.AssetsV1, | ||||
|   SyncRequestType.AssetExifsV1, | ||||
|   SyncRequestType.PartnerAssetsV1, | ||||
|   SyncRequestType.PartnerAssetExifsV1, | ||||
|   SyncRequestType.AlbumsV1, | ||||
|   SyncRequestType.AlbumUsersV1, | ||||
| ]; | ||||
|  | ||||
| const throwSessionRequired = () => { | ||||
| @@ -206,6 +207,43 @@ export class SyncService extends BaseService { | ||||
|           break; | ||||
|         } | ||||
|  | ||||
|         case SyncRequestType.AlbumsV1: { | ||||
|           const deletes = this.syncRepository.getAlbumDeletes( | ||||
|             auth.user.id, | ||||
|             checkpointMap[SyncEntityType.AlbumDeleteV1], | ||||
|           ); | ||||
|           for await (const { id, ...data } of deletes) { | ||||
|             response.write(serialize({ type: SyncEntityType.AlbumDeleteV1, updateId: id, data })); | ||||
|           } | ||||
|  | ||||
|           const upserts = this.syncRepository.getAlbumUpserts(auth.user.id, checkpointMap[SyncEntityType.AlbumV1]); | ||||
|           for await (const { updateId, ...data } of upserts) { | ||||
|             response.write(serialize({ type: SyncEntityType.AlbumV1, updateId, data })); | ||||
|           } | ||||
|  | ||||
|           break; | ||||
|         } | ||||
|  | ||||
|         case SyncRequestType.AlbumUsersV1: { | ||||
|           const deletes = this.syncRepository.getAlbumUserDeletes( | ||||
|             auth.user.id, | ||||
|             checkpointMap[SyncEntityType.AlbumUserDeleteV1], | ||||
|           ); | ||||
|           for await (const { id, ...data } of deletes) { | ||||
|             response.write(serialize({ type: SyncEntityType.AlbumUserDeleteV1, updateId: id, data })); | ||||
|           } | ||||
|  | ||||
|           const upserts = this.syncRepository.getAlbumUserUpserts( | ||||
|             auth.user.id, | ||||
|             checkpointMap[SyncEntityType.AlbumUserV1], | ||||
|           ); | ||||
|           for await (const { updateId, ...data } of upserts) { | ||||
|             response.write(serialize({ type: SyncEntityType.AlbumUserV1, updateId, data })); | ||||
|           } | ||||
|  | ||||
|           break; | ||||
|         } | ||||
|  | ||||
|         default: { | ||||
|           this.logger.warn(`Unsupported sync type: ${type}`); | ||||
|           break; | ||||
|   | ||||
| @@ -0,0 +1,8 @@ | ||||
| import { TriggerFunction, TriggerFunctionOptions } from 'src/sql-tools/from-code/decorators/trigger-function.decorator'; | ||||
|  | ||||
| export const AfterInsertTrigger = (options: Omit<TriggerFunctionOptions, 'timing' | 'actions'>) => | ||||
|   TriggerFunction({ | ||||
|     timing: 'after', | ||||
|     actions: ['insert'], | ||||
|     ...options, | ||||
|   }); | ||||
| @@ -1,6 +1,7 @@ | ||||
| export { schemaDiff } from 'src/sql-tools/diff'; | ||||
| export { schemaFromCode } from 'src/sql-tools/from-code'; | ||||
| export * from 'src/sql-tools/from-code/decorators/after-delete.decorator'; | ||||
| export * from 'src/sql-tools/from-code/decorators/after-insert.decorator'; | ||||
| export * from 'src/sql-tools/from-code/decorators/before-update.decorator'; | ||||
| export * from 'src/sql-tools/from-code/decorators/check.decorator'; | ||||
| export * from 'src/sql-tools/from-code/decorators/column.decorator'; | ||||
|   | ||||
| @@ -4,9 +4,11 @@ import { DateTime } from 'luxon'; | ||||
| import { createHash, randomBytes } from 'node:crypto'; | ||||
| import { Writable } from 'node:stream'; | ||||
| import { AssetFace } from 'src/database'; | ||||
| import { AssetJobStatus, Assets, DB, FaceSearch, Person, Sessions } from 'src/db'; | ||||
| import { AssetType, AssetVisibility, SourceType } from 'src/enum'; | ||||
| import { Albums, AssetJobStatus, Assets, DB, FaceSearch, Person, Sessions } from 'src/db'; | ||||
| import { AuthDto } from 'src/dtos/auth.dto'; | ||||
| import { AssetType, AssetVisibility, SourceType, SyncRequestType } from 'src/enum'; | ||||
| import { ActivityRepository } from 'src/repositories/activity.repository'; | ||||
| import { AlbumUserRepository } from 'src/repositories/album-user.repository'; | ||||
| import { AlbumRepository } from 'src/repositories/album.repository'; | ||||
| import { AssetJobRepository } from 'src/repositories/asset-job.repository'; | ||||
| import { AssetRepository } from 'src/repositories/asset.repository'; | ||||
| @@ -28,8 +30,9 @@ import { UserRepository } from 'src/repositories/user.repository'; | ||||
| import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; | ||||
| import { UserTable } from 'src/schema/tables/user.table'; | ||||
| import { BaseService } from 'src/services/base.service'; | ||||
| import { SyncService } from 'src/services/sync.service'; | ||||
| import { RepositoryInterface } from 'src/types'; | ||||
| import { newDate, newEmbedding, newUuid } from 'test/small.factory'; | ||||
| import { factory, newDate, newEmbedding, newUuid } from 'test/small.factory'; | ||||
| import { automock, ServiceOverrides } from 'test/utils'; | ||||
| import { Mocked } from 'vitest'; | ||||
|  | ||||
| @@ -39,6 +42,7 @@ const sha256 = (value: string) => createHash('sha256').update(value).digest('bas | ||||
| type RepositoriesTypes = { | ||||
|   activity: ActivityRepository; | ||||
|   album: AlbumRepository; | ||||
|   albumUser: AlbumUserRepository; | ||||
|   asset: AssetRepository; | ||||
|   assetJob: AssetJobRepository; | ||||
|   config: ConfigRepository; | ||||
| @@ -76,6 +80,61 @@ export type Context<R extends RepositoryOptions, S extends BaseService> = { | ||||
|   getRepository<T extends keyof RepositoriesTypes>(key: T): RepositoriesTypes[T]; | ||||
| }; | ||||
|  | ||||
| export type SyncTestOptions = { | ||||
|   db: Kysely<DB>; | ||||
| }; | ||||
|  | ||||
| export const newSyncAuthUser = () => { | ||||
|   const user = mediumFactory.userInsert(); | ||||
|   const session = mediumFactory.sessionInsert({ userId: user.id }); | ||||
|  | ||||
|   const auth = factory.auth({ | ||||
|     session, | ||||
|     user: { | ||||
|       id: user.id, | ||||
|       name: user.name, | ||||
|       email: user.email, | ||||
|     }, | ||||
|   }); | ||||
|  | ||||
|   return { | ||||
|     auth, | ||||
|     session, | ||||
|     user, | ||||
|     create: async (db: Kysely<DB>) => { | ||||
|       await new UserRepository(db).create(user); | ||||
|       await new SessionRepository(db).create(session); | ||||
|     }, | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export const newSyncTest = (options: SyncTestOptions) => { | ||||
|   const { sut, mocks, repos, getRepository } = newMediumService(SyncService, { | ||||
|     database: options.db, | ||||
|     repos: { | ||||
|       sync: 'real', | ||||
|       session: 'real', | ||||
|     }, | ||||
|   }); | ||||
|  | ||||
|   const testSync = async (auth: AuthDto, types: SyncRequestType[]) => { | ||||
|     const stream = mediumFactory.syncStream(); | ||||
|     // Wait for 2ms to ensure all updates are available and account for setTimeout inaccuracy | ||||
|     await new Promise((resolve) => setTimeout(resolve, 2)); | ||||
|     await sut.stream(auth, stream, { types }); | ||||
|  | ||||
|     return stream.getResponse(); | ||||
|   }; | ||||
|  | ||||
|   return { | ||||
|     sut, | ||||
|     mocks, | ||||
|     repos, | ||||
|     getRepository, | ||||
|     testSync, | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export const newMediumService = <R extends RepositoryOptions, S extends BaseService>( | ||||
|   Service: ClassConstructor<S>, | ||||
|   options: { | ||||
| @@ -125,6 +184,14 @@ export const getRepository = <K extends keyof RepositoriesTypes>(key: K, db: Kys | ||||
|       return new ActivityRepository(db); | ||||
|     } | ||||
|  | ||||
|     case 'album': { | ||||
|       return new AlbumRepository(db); | ||||
|     } | ||||
|  | ||||
|     case 'albumUser': { | ||||
|       return new AlbumUserRepository(db); | ||||
|     } | ||||
|  | ||||
|     case 'asset': { | ||||
|       return new AssetRepository(db); | ||||
|     } | ||||
| @@ -380,6 +447,19 @@ const assetInsert = (asset: Partial<Insertable<Assets>> = {}) => { | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| const albumInsert = (album: Partial<Insertable<Albums>> & { ownerId: string }) => { | ||||
|   const id = album.id || newUuid(); | ||||
|   const defaults: Omit<Insertable<Albums>, 'ownerId'> = { | ||||
|     albumName: 'Album', | ||||
|   }; | ||||
|  | ||||
|   return { | ||||
|     ...defaults, | ||||
|     ...album, | ||||
|     id, | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| const faceInsert = (face: Partial<Insertable<FaceSearch>> & { faceId: string }) => { | ||||
|   const defaults = { | ||||
|     faceId: face.faceId, | ||||
| @@ -502,6 +582,7 @@ export const mediumFactory = { | ||||
|   assetInsert, | ||||
|   assetFaceInsert, | ||||
|   assetJobStatusInsert, | ||||
|   albumInsert, | ||||
|   faceInsert, | ||||
|   personInsert, | ||||
|   sessionInsert, | ||||
|   | ||||
| @@ -1,910 +0,0 @@ | ||||
| import { AuthDto } from 'src/dtos/auth.dto'; | ||||
| import { SyncEntityType, SyncRequestType } from 'src/enum'; | ||||
| import { SYNC_TYPES_ORDER, SyncService } from 'src/services/sync.service'; | ||||
| import { mediumFactory, newMediumService } from 'test/medium.factory'; | ||||
| import { factory } from 'test/small.factory'; | ||||
| import { getKyselyDB } from 'test/utils'; | ||||
|  | ||||
| const setup = async () => { | ||||
|   const db = await getKyselyDB(); | ||||
|  | ||||
|   const { sut, mocks, repos, getRepository } = newMediumService(SyncService, { | ||||
|     database: db, | ||||
|     repos: { | ||||
|       sync: 'real', | ||||
|       session: 'real', | ||||
|     }, | ||||
|   }); | ||||
|  | ||||
|   const user = mediumFactory.userInsert(); | ||||
|   const session = mediumFactory.sessionInsert({ userId: user.id }); | ||||
|   const auth = factory.auth({ | ||||
|     session, | ||||
|     user: { | ||||
|       id: user.id, | ||||
|       name: user.name, | ||||
|       email: user.email, | ||||
|     }, | ||||
|   }); | ||||
|  | ||||
|   await getRepository('user').create(user); | ||||
|   await getRepository('session').create(session); | ||||
|  | ||||
|   const testSync = async (auth: AuthDto, types: SyncRequestType[]) => { | ||||
|     const stream = mediumFactory.syncStream(); | ||||
|     // Wait for 1ms to ensure all updates are available | ||||
|     await new Promise((resolve) => setTimeout(resolve, 1)); | ||||
|     await sut.stream(auth, stream, { types }); | ||||
|  | ||||
|     return stream.getResponse(); | ||||
|   }; | ||||
|  | ||||
|   return { | ||||
|     sut, | ||||
|     auth, | ||||
|     mocks, | ||||
|     repos, | ||||
|     getRepository, | ||||
|     testSync, | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| describe(SyncService.name, () => { | ||||
|   it('should have all the types in the ordering variable', () => { | ||||
|     for (const key in SyncRequestType) { | ||||
|       expect(SYNC_TYPES_ORDER).includes(key); | ||||
|     } | ||||
|  | ||||
|     expect(SYNC_TYPES_ORDER.length).toBe(Object.keys(SyncRequestType).length); | ||||
|   }); | ||||
|  | ||||
|   describe.concurrent(SyncEntityType.UserV1, () => { | ||||
|     it('should detect and sync the first user', async () => { | ||||
|       const { auth, sut, getRepository, testSync } = await setup(); | ||||
|  | ||||
|       const userRepo = getRepository('user'); | ||||
|       const user = await userRepo.get(auth.user.id, { withDeleted: false }); | ||||
|       if (!user) { | ||||
|         expect.fail('First user should exist'); | ||||
|       } | ||||
|  | ||||
|       const initialSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); | ||||
|       expect(initialSyncResponse).toHaveLength(1); | ||||
|       expect(initialSyncResponse).toEqual([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: { | ||||
|             deletedAt: user.deletedAt, | ||||
|             email: user.email, | ||||
|             id: user.id, | ||||
|             name: user.name, | ||||
|           }, | ||||
|           type: 'UserV1', | ||||
|         }, | ||||
|       ]); | ||||
|  | ||||
|       const acks = [initialSyncResponse[0].ack]; | ||||
|       await sut.setAcks(auth, { acks }); | ||||
|       const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); | ||||
|  | ||||
|       expect(ackSyncResponse).toHaveLength(0); | ||||
|     }); | ||||
|  | ||||
|     it('should detect and sync a soft deleted user', async () => { | ||||
|       const { auth, sut, getRepository, testSync } = await setup(); | ||||
|  | ||||
|       const deletedAt = new Date().toISOString(); | ||||
|       const deletedUser = mediumFactory.userInsert({ deletedAt }); | ||||
|       const deleted = await getRepository('user').create(deletedUser); | ||||
|  | ||||
|       const response = await testSync(auth, [SyncRequestType.UsersV1]); | ||||
|  | ||||
|       expect(response).toHaveLength(2); | ||||
|       expect(response).toEqual( | ||||
|         expect.arrayContaining([ | ||||
|           { | ||||
|             ack: expect.any(String), | ||||
|             data: { | ||||
|               deletedAt: null, | ||||
|               email: auth.user.email, | ||||
|               id: auth.user.id, | ||||
|               name: auth.user.name, | ||||
|             }, | ||||
|             type: 'UserV1', | ||||
|           }, | ||||
|           { | ||||
|             ack: expect.any(String), | ||||
|             data: { | ||||
|               deletedAt, | ||||
|               email: deleted.email, | ||||
|               id: deleted.id, | ||||
|               name: deleted.name, | ||||
|             }, | ||||
|             type: 'UserV1', | ||||
|           }, | ||||
|         ]), | ||||
|       ); | ||||
|  | ||||
|       const acks = [response[1].ack]; | ||||
|       await sut.setAcks(auth, { acks }); | ||||
|       const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); | ||||
|  | ||||
|       expect(ackSyncResponse).toHaveLength(0); | ||||
|     }); | ||||
|  | ||||
|     it('should detect and sync a deleted user', async () => { | ||||
|       const { auth, sut, getRepository, testSync } = await setup(); | ||||
|  | ||||
|       const userRepo = getRepository('user'); | ||||
|       const user = mediumFactory.userInsert(); | ||||
|       await userRepo.create(user); | ||||
|       await userRepo.delete({ id: user.id }, true); | ||||
|  | ||||
|       const response = await testSync(auth, [SyncRequestType.UsersV1]); | ||||
|  | ||||
|       expect(response).toHaveLength(2); | ||||
|       expect(response).toEqual( | ||||
|         expect.arrayContaining([ | ||||
|           { | ||||
|             ack: expect.any(String), | ||||
|             data: { | ||||
|               userId: user.id, | ||||
|             }, | ||||
|             type: 'UserDeleteV1', | ||||
|           }, | ||||
|           { | ||||
|             ack: expect.any(String), | ||||
|             data: { | ||||
|               deletedAt: null, | ||||
|               email: auth.user.email, | ||||
|               id: auth.user.id, | ||||
|               name: auth.user.name, | ||||
|             }, | ||||
|             type: 'UserV1', | ||||
|           }, | ||||
|         ]), | ||||
|       ); | ||||
|  | ||||
|       const acks = response.map(({ ack }) => ack); | ||||
|       await sut.setAcks(auth, { acks }); | ||||
|       const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); | ||||
|  | ||||
|       expect(ackSyncResponse).toHaveLength(0); | ||||
|     }); | ||||
|  | ||||
|     it('should sync a user and then an update to that same user', async () => { | ||||
|       const { auth, sut, getRepository, testSync } = await setup(); | ||||
|  | ||||
|       const initialSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); | ||||
|  | ||||
|       expect(initialSyncResponse).toHaveLength(1); | ||||
|       expect(initialSyncResponse).toEqual( | ||||
|         expect.arrayContaining([ | ||||
|           { | ||||
|             ack: expect.any(String), | ||||
|             data: { | ||||
|               deletedAt: null, | ||||
|               email: auth.user.email, | ||||
|               id: auth.user.id, | ||||
|               name: auth.user.name, | ||||
|             }, | ||||
|             type: 'UserV1', | ||||
|           }, | ||||
|         ]), | ||||
|       ); | ||||
|  | ||||
|       const acks = [initialSyncResponse[0].ack]; | ||||
|       await sut.setAcks(auth, { acks }); | ||||
|  | ||||
|       const userRepo = getRepository('user'); | ||||
|       const updated = await userRepo.update(auth.user.id, { name: 'new name' }); | ||||
|       const updatedSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); | ||||
|  | ||||
|       expect(updatedSyncResponse).toHaveLength(1); | ||||
|       expect(updatedSyncResponse).toEqual( | ||||
|         expect.arrayContaining([ | ||||
|           { | ||||
|             ack: expect.any(String), | ||||
|             data: { | ||||
|               deletedAt: null, | ||||
|               email: auth.user.email, | ||||
|               id: auth.user.id, | ||||
|               name: updated.name, | ||||
|             }, | ||||
|             type: 'UserV1', | ||||
|           }, | ||||
|         ]), | ||||
|       ); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe.concurrent(SyncEntityType.PartnerV1, () => { | ||||
|     it('should detect and sync the first partner', async () => { | ||||
|       const { auth, sut, getRepository, testSync } = await setup(); | ||||
|  | ||||
|       const user1 = auth.user; | ||||
|       const userRepo = getRepository('user'); | ||||
|       const partnerRepo = getRepository('partner'); | ||||
|  | ||||
|       const user2 = mediumFactory.userInsert(); | ||||
|       await userRepo.create(user2); | ||||
|  | ||||
|       const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user1.id }); | ||||
|  | ||||
|       const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); | ||||
|  | ||||
|       expect(initialSyncResponse).toHaveLength(1); | ||||
|       expect(initialSyncResponse).toEqual( | ||||
|         expect.arrayContaining([ | ||||
|           { | ||||
|             ack: expect.any(String), | ||||
|             data: { | ||||
|               inTimeline: partner.inTimeline, | ||||
|               sharedById: partner.sharedById, | ||||
|               sharedWithId: partner.sharedWithId, | ||||
|             }, | ||||
|             type: 'PartnerV1', | ||||
|           }, | ||||
|         ]), | ||||
|       ); | ||||
|  | ||||
|       const acks = [initialSyncResponse[0].ack]; | ||||
|       await sut.setAcks(auth, { acks }); | ||||
|  | ||||
|       const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); | ||||
|  | ||||
|       expect(ackSyncResponse).toHaveLength(0); | ||||
|     }); | ||||
|  | ||||
|     it('should detect and sync a deleted partner', async () => { | ||||
|       const { auth, sut, getRepository, testSync } = await setup(); | ||||
|  | ||||
|       const userRepo = getRepository('user'); | ||||
|       const user1 = auth.user; | ||||
|       const user2 = mediumFactory.userInsert(); | ||||
|       await userRepo.create(user2); | ||||
|  | ||||
|       const partnerRepo = getRepository('partner'); | ||||
|       const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user1.id }); | ||||
|       await partnerRepo.remove(partner); | ||||
|  | ||||
|       const response = await testSync(auth, [SyncRequestType.PartnersV1]); | ||||
|  | ||||
|       expect(response).toHaveLength(1); | ||||
|       expect(response).toEqual( | ||||
|         expect.arrayContaining([ | ||||
|           { | ||||
|             ack: expect.any(String), | ||||
|             data: { | ||||
|               sharedById: partner.sharedById, | ||||
|               sharedWithId: partner.sharedWithId, | ||||
|             }, | ||||
|             type: 'PartnerDeleteV1', | ||||
|           }, | ||||
|         ]), | ||||
|       ); | ||||
|  | ||||
|       const acks = response.map(({ ack }) => ack); | ||||
|       await sut.setAcks(auth, { acks }); | ||||
|  | ||||
|       const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); | ||||
|  | ||||
|       expect(ackSyncResponse).toHaveLength(0); | ||||
|     }); | ||||
|  | ||||
|     it('should detect and sync a partner share both to and from another user', async () => { | ||||
|       const { auth, sut, getRepository, testSync } = await setup(); | ||||
|  | ||||
|       const userRepo = getRepository('user'); | ||||
|       const user1 = auth.user; | ||||
|       const user2 = await userRepo.create(mediumFactory.userInsert()); | ||||
|  | ||||
|       const partnerRepo = getRepository('partner'); | ||||
|       const partner1 = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user1.id }); | ||||
|       const partner2 = await partnerRepo.create({ sharedById: user1.id, sharedWithId: user2.id }); | ||||
|  | ||||
|       const response = await testSync(auth, [SyncRequestType.PartnersV1]); | ||||
|  | ||||
|       expect(response).toHaveLength(2); | ||||
|       expect(response).toEqual( | ||||
|         expect.arrayContaining([ | ||||
|           { | ||||
|             ack: expect.any(String), | ||||
|             data: { | ||||
|               inTimeline: partner1.inTimeline, | ||||
|               sharedById: partner1.sharedById, | ||||
|               sharedWithId: partner1.sharedWithId, | ||||
|             }, | ||||
|             type: 'PartnerV1', | ||||
|           }, | ||||
|           { | ||||
|             ack: expect.any(String), | ||||
|             data: { | ||||
|               inTimeline: partner2.inTimeline, | ||||
|               sharedById: partner2.sharedById, | ||||
|               sharedWithId: partner2.sharedWithId, | ||||
|             }, | ||||
|             type: 'PartnerV1', | ||||
|           }, | ||||
|         ]), | ||||
|       ); | ||||
|  | ||||
|       await sut.setAcks(auth, { acks: [response[1].ack] }); | ||||
|  | ||||
|       const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); | ||||
|  | ||||
|       expect(ackSyncResponse).toHaveLength(0); | ||||
|     }); | ||||
|  | ||||
|     it('should sync a partner and then an update to that same partner', async () => { | ||||
|       const { auth, sut, getRepository, testSync } = await setup(); | ||||
|  | ||||
|       const userRepo = getRepository('user'); | ||||
|       const user1 = auth.user; | ||||
|       const user2 = await userRepo.create(mediumFactory.userInsert()); | ||||
|  | ||||
|       const partnerRepo = getRepository('partner'); | ||||
|       const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user1.id }); | ||||
|  | ||||
|       const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); | ||||
|  | ||||
|       expect(initialSyncResponse).toHaveLength(1); | ||||
|       expect(initialSyncResponse).toEqual( | ||||
|         expect.arrayContaining([ | ||||
|           { | ||||
|             ack: expect.any(String), | ||||
|             data: { | ||||
|               inTimeline: partner.inTimeline, | ||||
|               sharedById: partner.sharedById, | ||||
|               sharedWithId: partner.sharedWithId, | ||||
|             }, | ||||
|             type: 'PartnerV1', | ||||
|           }, | ||||
|         ]), | ||||
|       ); | ||||
|  | ||||
|       const acks = [initialSyncResponse[0].ack]; | ||||
|       await sut.setAcks(auth, { acks }); | ||||
|  | ||||
|       const updated = await partnerRepo.update( | ||||
|         { sharedById: partner.sharedById, sharedWithId: partner.sharedWithId }, | ||||
|         { inTimeline: true }, | ||||
|       ); | ||||
|  | ||||
|       const updatedSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); | ||||
|  | ||||
|       expect(updatedSyncResponse).toHaveLength(1); | ||||
|       expect(updatedSyncResponse).toEqual( | ||||
|         expect.arrayContaining([ | ||||
|           { | ||||
|             ack: expect.any(String), | ||||
|             data: { | ||||
|               inTimeline: updated.inTimeline, | ||||
|               sharedById: updated.sharedById, | ||||
|               sharedWithId: updated.sharedWithId, | ||||
|             }, | ||||
|             type: 'PartnerV1', | ||||
|           }, | ||||
|         ]), | ||||
|       ); | ||||
|     }); | ||||
|  | ||||
|     it('should not sync a partner or partner delete for an unrelated user', async () => { | ||||
|       const { auth, getRepository, testSync } = await setup(); | ||||
|  | ||||
|       const userRepo = getRepository('user'); | ||||
|       const user2 = await userRepo.create(mediumFactory.userInsert()); | ||||
|       const user3 = await userRepo.create(mediumFactory.userInsert()); | ||||
|  | ||||
|       const partnerRepo = getRepository('partner'); | ||||
|       const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user3.id }); | ||||
|  | ||||
|       expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0); | ||||
|  | ||||
|       await partnerRepo.remove(partner); | ||||
|  | ||||
|       expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0); | ||||
|     }); | ||||
|  | ||||
|     it('should not sync a partner delete after a user is deleted', async () => { | ||||
|       const { auth, getRepository, testSync } = await setup(); | ||||
|  | ||||
|       const userRepo = getRepository('user'); | ||||
|       const user2 = await userRepo.create(mediumFactory.userInsert()); | ||||
|  | ||||
|       const partnerRepo = getRepository('partner'); | ||||
|       await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id }); | ||||
|       await userRepo.delete({ id: user2.id }, true); | ||||
|  | ||||
|       expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe.concurrent(SyncEntityType.AssetV1, () => { | ||||
|     it('should detect and sync the first asset', async () => { | ||||
|       const { auth, sut, getRepository, testSync } = await setup(); | ||||
|  | ||||
|       const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA='; | ||||
|       const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA='; | ||||
|       const date = new Date().toISOString(); | ||||
|  | ||||
|       const assetRepo = getRepository('asset'); | ||||
|       const asset = mediumFactory.assetInsert({ | ||||
|         ownerId: auth.user.id, | ||||
|         checksum: Buffer.from(checksum, 'base64'), | ||||
|         thumbhash: Buffer.from(thumbhash, 'base64'), | ||||
|         fileCreatedAt: date, | ||||
|         fileModifiedAt: date, | ||||
|         localDateTime: date, | ||||
|         deletedAt: null, | ||||
|       }); | ||||
|       await assetRepo.create(asset); | ||||
|  | ||||
|       const initialSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]); | ||||
|  | ||||
|       expect(initialSyncResponse).toHaveLength(1); | ||||
|       expect(initialSyncResponse).toEqual( | ||||
|         expect.arrayContaining([ | ||||
|           { | ||||
|             ack: expect.any(String), | ||||
|             data: { | ||||
|               id: asset.id, | ||||
|               ownerId: asset.ownerId, | ||||
|               thumbhash, | ||||
|               checksum, | ||||
|               deletedAt: asset.deletedAt, | ||||
|               fileCreatedAt: asset.fileCreatedAt, | ||||
|               fileModifiedAt: asset.fileModifiedAt, | ||||
|               isFavorite: asset.isFavorite, | ||||
|               localDateTime: asset.localDateTime, | ||||
|               type: asset.type, | ||||
|               visibility: asset.visibility, | ||||
|             }, | ||||
|             type: 'AssetV1', | ||||
|           }, | ||||
|         ]), | ||||
|       ); | ||||
|  | ||||
|       const acks = [initialSyncResponse[0].ack]; | ||||
|       await sut.setAcks(auth, { acks }); | ||||
|  | ||||
|       const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]); | ||||
|  | ||||
|       expect(ackSyncResponse).toHaveLength(0); | ||||
|     }); | ||||
|  | ||||
|     it('should detect and sync a deleted asset', async () => { | ||||
|       const { auth, sut, getRepository, testSync } = await setup(); | ||||
|  | ||||
|       const assetRepo = getRepository('asset'); | ||||
|       const asset = mediumFactory.assetInsert({ ownerId: auth.user.id }); | ||||
|       await assetRepo.create(asset); | ||||
|       await assetRepo.remove(asset); | ||||
|  | ||||
|       const response = await testSync(auth, [SyncRequestType.AssetsV1]); | ||||
|  | ||||
|       expect(response).toHaveLength(1); | ||||
|       expect(response).toEqual( | ||||
|         expect.arrayContaining([ | ||||
|           { | ||||
|             ack: expect.any(String), | ||||
|             data: { | ||||
|               assetId: asset.id, | ||||
|             }, | ||||
|             type: 'AssetDeleteV1', | ||||
|           }, | ||||
|         ]), | ||||
|       ); | ||||
|  | ||||
|       const acks = response.map(({ ack }) => ack); | ||||
|       await sut.setAcks(auth, { acks }); | ||||
|  | ||||
|       const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]); | ||||
|  | ||||
|       expect(ackSyncResponse).toHaveLength(0); | ||||
|     }); | ||||
|  | ||||
|     it('should not sync an asset or asset delete for an unrelated user', async () => { | ||||
|       const { auth, getRepository, testSync } = await setup(); | ||||
|  | ||||
|       const userRepo = getRepository('user'); | ||||
|       const user2 = mediumFactory.userInsert(); | ||||
|       await userRepo.create(user2); | ||||
|  | ||||
|       const sessionRepo = getRepository('session'); | ||||
|       const session = mediumFactory.sessionInsert({ userId: user2.id }); | ||||
|       await sessionRepo.create(session); | ||||
|  | ||||
|       const assetRepo = getRepository('asset'); | ||||
|       const asset = mediumFactory.assetInsert({ ownerId: user2.id }); | ||||
|       await assetRepo.create(asset); | ||||
|  | ||||
|       const auth2 = factory.auth({ session, user: user2 }); | ||||
|  | ||||
|       expect(await testSync(auth2, [SyncRequestType.AssetsV1])).toHaveLength(1); | ||||
|       expect(await testSync(auth, [SyncRequestType.AssetsV1])).toHaveLength(0); | ||||
|  | ||||
|       await assetRepo.remove(asset); | ||||
|       expect(await testSync(auth2, [SyncRequestType.AssetsV1])).toHaveLength(1); | ||||
|       expect(await testSync(auth, [SyncRequestType.AssetsV1])).toHaveLength(0); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe.concurrent(SyncRequestType.PartnerAssetsV1, () => { | ||||
|     it('should detect and sync the first partner asset', async () => { | ||||
|       const { auth, sut, getRepository, testSync } = await setup(); | ||||
|  | ||||
|       const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA='; | ||||
|       const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA='; | ||||
|       const date = new Date().toISOString(); | ||||
|  | ||||
|       const userRepo = getRepository('user'); | ||||
|       const user2 = mediumFactory.userInsert(); | ||||
|       await userRepo.create(user2); | ||||
|  | ||||
|       const assetRepo = getRepository('asset'); | ||||
|       const asset = mediumFactory.assetInsert({ | ||||
|         ownerId: user2.id, | ||||
|         checksum: Buffer.from(checksum, 'base64'), | ||||
|         thumbhash: Buffer.from(thumbhash, 'base64'), | ||||
|         fileCreatedAt: date, | ||||
|         fileModifiedAt: date, | ||||
|         localDateTime: date, | ||||
|         deletedAt: null, | ||||
|       }); | ||||
|       await assetRepo.create(asset); | ||||
|  | ||||
|       const partnerRepo = getRepository('partner'); | ||||
|       await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id }); | ||||
|  | ||||
|       const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); | ||||
|  | ||||
|       expect(initialSyncResponse).toHaveLength(1); | ||||
|       expect(initialSyncResponse).toEqual( | ||||
|         expect.arrayContaining([ | ||||
|           { | ||||
|             ack: expect.any(String), | ||||
|             data: { | ||||
|               id: asset.id, | ||||
|               ownerId: asset.ownerId, | ||||
|               thumbhash, | ||||
|               checksum, | ||||
|               deletedAt: null, | ||||
|               fileCreatedAt: date, | ||||
|               fileModifiedAt: date, | ||||
|               isFavorite: false, | ||||
|               localDateTime: date, | ||||
|               type: asset.type, | ||||
|               visibility: asset.visibility, | ||||
|             }, | ||||
|             type: SyncEntityType.PartnerAssetV1, | ||||
|           }, | ||||
|         ]), | ||||
|       ); | ||||
|  | ||||
|       const acks = [initialSyncResponse[0].ack]; | ||||
|       await sut.setAcks(auth, { acks }); | ||||
|  | ||||
|       const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); | ||||
|  | ||||
|       expect(ackSyncResponse).toHaveLength(0); | ||||
|     }); | ||||
|  | ||||
|     it('should detect and sync a deleted partner asset', async () => { | ||||
|       const { auth, sut, getRepository, testSync } = await setup(); | ||||
|  | ||||
|       const userRepo = getRepository('user'); | ||||
|       const user2 = mediumFactory.userInsert(); | ||||
|       await userRepo.create(user2); | ||||
|       const asset = mediumFactory.assetInsert({ ownerId: user2.id }); | ||||
|  | ||||
|       const assetRepo = getRepository('asset'); | ||||
|       await assetRepo.create(asset); | ||||
|  | ||||
|       const partnerRepo = getRepository('partner'); | ||||
|       await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id }); | ||||
|       await assetRepo.remove(asset); | ||||
|  | ||||
|       const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); | ||||
|  | ||||
|       expect(response).toHaveLength(1); | ||||
|       expect(response).toEqual( | ||||
|         expect.arrayContaining([ | ||||
|           { | ||||
|             ack: expect.any(String), | ||||
|             data: { | ||||
|               assetId: asset.id, | ||||
|             }, | ||||
|             type: SyncEntityType.PartnerAssetDeleteV1, | ||||
|           }, | ||||
|         ]), | ||||
|       ); | ||||
|  | ||||
|       const acks = response.map(({ ack }) => ack); | ||||
|       await sut.setAcks(auth, { acks }); | ||||
|  | ||||
|       const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); | ||||
|  | ||||
|       expect(ackSyncResponse).toHaveLength(0); | ||||
|     }); | ||||
|  | ||||
|     it('should not sync a deleted partner asset due to a user delete', async () => { | ||||
|       const { auth, getRepository, testSync } = await setup(); | ||||
|  | ||||
|       const userRepo = getRepository('user'); | ||||
|       const user2 = mediumFactory.userInsert(); | ||||
|       await userRepo.create(user2); | ||||
|  | ||||
|       const partnerRepo = getRepository('partner'); | ||||
|       await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id }); | ||||
|  | ||||
|       const assetRepo = getRepository('asset'); | ||||
|       await assetRepo.create(mediumFactory.assetInsert({ ownerId: user2.id })); | ||||
|  | ||||
|       await userRepo.delete({ id: user2.id }, true); | ||||
|  | ||||
|       const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); | ||||
|       expect(response).toHaveLength(0); | ||||
|     }); | ||||
|  | ||||
|     it('should not sync a deleted partner asset due to a partner delete (unshare)', async () => { | ||||
|       const { auth, getRepository, testSync } = await setup(); | ||||
|  | ||||
|       const userRepo = getRepository('user'); | ||||
|       const user2 = mediumFactory.userInsert(); | ||||
|       await userRepo.create(user2); | ||||
|  | ||||
|       const assetRepo = getRepository('asset'); | ||||
|       await assetRepo.create(mediumFactory.assetInsert({ ownerId: user2.id })); | ||||
|  | ||||
|       const partnerRepo = getRepository('partner'); | ||||
|       const partner = { sharedById: user2.id, sharedWithId: auth.user.id }; | ||||
|       await partnerRepo.create(partner); | ||||
|  | ||||
|       await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(1); | ||||
|  | ||||
|       await partnerRepo.remove(partner); | ||||
|  | ||||
|       await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0); | ||||
|     }); | ||||
|  | ||||
|     it('should not sync an asset or asset delete for own user', async () => { | ||||
|       const { auth, getRepository, testSync } = await setup(); | ||||
|  | ||||
|       const userRepo = getRepository('user'); | ||||
|       const user2 = mediumFactory.userInsert(); | ||||
|       await userRepo.create(user2); | ||||
|  | ||||
|       const assetRepo = getRepository('asset'); | ||||
|       const asset = mediumFactory.assetInsert({ ownerId: auth.user.id }); | ||||
|       await assetRepo.create(asset); | ||||
|  | ||||
|       const partnerRepo = getRepository('partner'); | ||||
|       await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id }); | ||||
|  | ||||
|       await expect(testSync(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1); | ||||
|       await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0); | ||||
|  | ||||
|       await assetRepo.remove(asset); | ||||
|  | ||||
|       await expect(testSync(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1); | ||||
|       await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0); | ||||
|     }); | ||||
|  | ||||
|     it('should not sync an asset or asset delete for unrelated user', async () => { | ||||
|       const { auth, getRepository, testSync } = await setup(); | ||||
|  | ||||
|       const userRepo = getRepository('user'); | ||||
|       const user2 = mediumFactory.userInsert(); | ||||
|       await userRepo.create(user2); | ||||
|  | ||||
|       const sessionRepo = getRepository('session'); | ||||
|       const session = mediumFactory.sessionInsert({ userId: user2.id }); | ||||
|       await sessionRepo.create(session); | ||||
|  | ||||
|       const auth2 = factory.auth({ session, user: user2 }); | ||||
|  | ||||
|       const assetRepo = getRepository('asset'); | ||||
|       const asset = mediumFactory.assetInsert({ ownerId: user2.id }); | ||||
|       await assetRepo.create(asset); | ||||
|  | ||||
|       await expect(testSync(auth2, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1); | ||||
|       await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0); | ||||
|  | ||||
|       await assetRepo.remove(asset); | ||||
|  | ||||
|       await expect(testSync(auth2, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1); | ||||
|       await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe.concurrent(SyncRequestType.AssetExifsV1, () => { | ||||
|     it('should detect and sync the first asset exif', async () => { | ||||
|       const { auth, sut, getRepository, testSync } = await setup(); | ||||
|  | ||||
|       const assetRepo = getRepository('asset'); | ||||
|       const asset = mediumFactory.assetInsert({ ownerId: auth.user.id }); | ||||
|       await assetRepo.create(asset); | ||||
|       await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' }); | ||||
|  | ||||
|       const initialSyncResponse = await testSync(auth, [SyncRequestType.AssetExifsV1]); | ||||
|  | ||||
|       expect(initialSyncResponse).toHaveLength(1); | ||||
|       expect(initialSyncResponse).toEqual( | ||||
|         expect.arrayContaining([ | ||||
|           { | ||||
|             ack: expect.any(String), | ||||
|             data: { | ||||
|               assetId: asset.id, | ||||
|               city: null, | ||||
|               country: null, | ||||
|               dateTimeOriginal: null, | ||||
|               description: '', | ||||
|               exifImageHeight: null, | ||||
|               exifImageWidth: null, | ||||
|               exposureTime: null, | ||||
|               fNumber: null, | ||||
|               fileSizeInByte: null, | ||||
|               focalLength: null, | ||||
|               fps: null, | ||||
|               iso: null, | ||||
|               latitude: null, | ||||
|               lensModel: null, | ||||
|               longitude: null, | ||||
|               make: 'Canon', | ||||
|               model: null, | ||||
|               modifyDate: null, | ||||
|               orientation: null, | ||||
|               profileDescription: null, | ||||
|               projectionType: null, | ||||
|               rating: null, | ||||
|               state: null, | ||||
|               timeZone: null, | ||||
|             }, | ||||
|             type: SyncEntityType.AssetExifV1, | ||||
|           }, | ||||
|         ]), | ||||
|       ); | ||||
|  | ||||
|       const acks = [initialSyncResponse[0].ack]; | ||||
|       await sut.setAcks(auth, { acks }); | ||||
|  | ||||
|       const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetExifsV1]); | ||||
|  | ||||
|       expect(ackSyncResponse).toHaveLength(0); | ||||
|     }); | ||||
|  | ||||
|     it('should only sync asset exif for own user', async () => { | ||||
|       const { auth, getRepository, testSync } = await setup(); | ||||
|  | ||||
|       const userRepo = getRepository('user'); | ||||
|       const user2 = mediumFactory.userInsert(); | ||||
|       await userRepo.create(user2); | ||||
|  | ||||
|       const partnerRepo = getRepository('partner'); | ||||
|       await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id }); | ||||
|  | ||||
|       const assetRepo = getRepository('asset'); | ||||
|       const asset = mediumFactory.assetInsert({ ownerId: user2.id }); | ||||
|       await assetRepo.create(asset); | ||||
|       await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' }); | ||||
|  | ||||
|       const sessionRepo = getRepository('session'); | ||||
|       const session = mediumFactory.sessionInsert({ userId: user2.id }); | ||||
|       await sessionRepo.create(session); | ||||
|  | ||||
|       const auth2 = factory.auth({ session, user: user2 }); | ||||
|       await expect(testSync(auth2, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1); | ||||
|       await expect(testSync(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(0); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe.concurrent(SyncRequestType.PartnerAssetExifsV1, () => { | ||||
|     it('should detect and sync the first partner asset exif', async () => { | ||||
|       const { auth, sut, getRepository, testSync } = await setup(); | ||||
|  | ||||
|       const userRepo = getRepository('user'); | ||||
|       const user2 = mediumFactory.userInsert(); | ||||
|       await userRepo.create(user2); | ||||
|  | ||||
|       const partnerRepo = getRepository('partner'); | ||||
|       await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id }); | ||||
|  | ||||
|       const assetRepo = getRepository('asset'); | ||||
|       const asset = mediumFactory.assetInsert({ ownerId: user2.id }); | ||||
|       await assetRepo.create(asset); | ||||
|       await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' }); | ||||
|  | ||||
|       const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]); | ||||
|  | ||||
|       expect(initialSyncResponse).toHaveLength(1); | ||||
|       expect(initialSyncResponse).toEqual( | ||||
|         expect.arrayContaining([ | ||||
|           { | ||||
|             ack: expect.any(String), | ||||
|             data: { | ||||
|               assetId: asset.id, | ||||
|               city: null, | ||||
|               country: null, | ||||
|               dateTimeOriginal: null, | ||||
|               description: '', | ||||
|               exifImageHeight: null, | ||||
|               exifImageWidth: null, | ||||
|               exposureTime: null, | ||||
|               fNumber: null, | ||||
|               fileSizeInByte: null, | ||||
|               focalLength: null, | ||||
|               fps: null, | ||||
|               iso: null, | ||||
|               latitude: null, | ||||
|               lensModel: null, | ||||
|               longitude: null, | ||||
|               make: 'Canon', | ||||
|               model: null, | ||||
|               modifyDate: null, | ||||
|               orientation: null, | ||||
|               profileDescription: null, | ||||
|               projectionType: null, | ||||
|               rating: null, | ||||
|               state: null, | ||||
|               timeZone: null, | ||||
|             }, | ||||
|             type: SyncEntityType.PartnerAssetExifV1, | ||||
|           }, | ||||
|         ]), | ||||
|       ); | ||||
|  | ||||
|       const acks = [initialSyncResponse[0].ack]; | ||||
|       await sut.setAcks(auth, { acks }); | ||||
|  | ||||
|       const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]); | ||||
|  | ||||
|       expect(ackSyncResponse).toHaveLength(0); | ||||
|     }); | ||||
|  | ||||
|     it('should not sync partner asset exif for own user', async () => { | ||||
|       const { auth, getRepository, testSync } = await setup(); | ||||
|  | ||||
|       const userRepo = getRepository('user'); | ||||
|       const user2 = mediumFactory.userInsert(); | ||||
|       await userRepo.create(user2); | ||||
|  | ||||
|       const partnerRepo = getRepository('partner'); | ||||
|       await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id }); | ||||
|  | ||||
|       const assetRepo = getRepository('asset'); | ||||
|       const asset = mediumFactory.assetInsert({ ownerId: auth.user.id }); | ||||
|       await assetRepo.create(asset); | ||||
|       await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' }); | ||||
|  | ||||
|       await expect(testSync(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1); | ||||
|       await expect(testSync(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0); | ||||
|     }); | ||||
|  | ||||
|     it('should not sync partner asset exif for unrelated user', async () => { | ||||
|       const { auth, getRepository, testSync } = await setup(); | ||||
|  | ||||
|       const userRepo = getRepository('user'); | ||||
|  | ||||
|       const user2 = mediumFactory.userInsert(); | ||||
|       const user3 = mediumFactory.userInsert(); | ||||
|       await Promise.all([userRepo.create(user2), userRepo.create(user3)]); | ||||
|  | ||||
|       const partnerRepo = getRepository('partner'); | ||||
|       await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id }); | ||||
|  | ||||
|       const assetRepo = getRepository('asset'); | ||||
|       const asset = mediumFactory.assetInsert({ ownerId: user3.id }); | ||||
|       await assetRepo.create(asset); | ||||
|       await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' }); | ||||
|  | ||||
|       const sessionRepo = getRepository('session'); | ||||
|       const session = mediumFactory.sessionInsert({ userId: user3.id }); | ||||
|       await sessionRepo.create(session); | ||||
|  | ||||
|       const authUser3 = factory.auth({ session, user: user3 }); | ||||
|       await expect(testSync(authUser3, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1); | ||||
|       await expect(testSync(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										269
									
								
								server/test/medium/specs/sync/sync-album-user.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										269
									
								
								server/test/medium/specs/sync/sync-album-user.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,269 @@ | ||||
| import { Kysely } from 'kysely'; | ||||
| import { DB } from 'src/db'; | ||||
| import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum'; | ||||
| import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory'; | ||||
| import { getKyselyDB } from 'test/utils'; | ||||
|  | ||||
| let defaultDatabase: Kysely<DB>; | ||||
|  | ||||
| const setup = async (db?: Kysely<DB>) => { | ||||
|   const database = db || defaultDatabase; | ||||
|   const result = newSyncTest({ db: database }); | ||||
|   const { auth, create } = newSyncAuthUser(); | ||||
|   await create(database); | ||||
|   return { ...result, auth }; | ||||
| }; | ||||
|  | ||||
| beforeAll(async () => { | ||||
|   defaultDatabase = await getKyselyDB(); | ||||
| }); | ||||
|  | ||||
| describe(SyncRequestType.AlbumUsersV1, () => { | ||||
|   it('should sync an album user with the correct properties', async () => { | ||||
|     const { auth, getRepository, testSync } = await setup(); | ||||
|  | ||||
|     const albumRepo = getRepository('album'); | ||||
|     const albumUserRepo = getRepository('albumUser'); | ||||
|     const userRepo = getRepository('user'); | ||||
|  | ||||
|     const album = mediumFactory.albumInsert({ ownerId: auth.user.id }); | ||||
|     await albumRepo.create(album, [], []); | ||||
|  | ||||
|     const user = mediumFactory.userInsert(); | ||||
|     await userRepo.create(user); | ||||
|  | ||||
|     const albumUser = { albumsId: album.id, usersId: user.id, role: AlbumUserRole.EDITOR }; | ||||
|     await albumUserRepo.create(albumUser); | ||||
|  | ||||
|     await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([ | ||||
|       { | ||||
|         ack: expect.any(String), | ||||
|         data: expect.objectContaining({ | ||||
|           albumId: albumUser.albumsId, | ||||
|           role: albumUser.role, | ||||
|           userId: albumUser.usersId, | ||||
|         }), | ||||
|         type: SyncEntityType.AlbumUserV1, | ||||
|       }, | ||||
|     ]); | ||||
|   }); | ||||
|   describe('owner', () => { | ||||
|     it('should detect and sync a new shared user', async () => { | ||||
|       const { auth, testSync, getRepository } = await setup(); | ||||
|  | ||||
|       const albumRepo = getRepository('album'); | ||||
|       const albumUserRepo = getRepository('albumUser'); | ||||
|       const userRepo = getRepository('user'); | ||||
|  | ||||
|       const user1 = mediumFactory.userInsert(); | ||||
|       await userRepo.create(user1); | ||||
|  | ||||
|       const album = mediumFactory.albumInsert({ ownerId: auth.user.id }); | ||||
|       await albumRepo.create(album, [], []); | ||||
|  | ||||
|       const albumUser = { albumsId: album.id, usersId: user1.id, role: AlbumUserRole.EDITOR }; | ||||
|       await albumUserRepo.create(albumUser); | ||||
|  | ||||
|       await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: expect.objectContaining({ | ||||
|             albumId: albumUser.albumsId, | ||||
|             role: albumUser.role, | ||||
|             userId: albumUser.usersId, | ||||
|           }), | ||||
|           type: SyncEntityType.AlbumUserV1, | ||||
|         }, | ||||
|       ]); | ||||
|     }); | ||||
|  | ||||
|     it('should detect and sync an updated shared user', async () => { | ||||
|       const { auth, testSync, getRepository, sut } = await setup(); | ||||
|  | ||||
|       const albumRepo = getRepository('album'); | ||||
|       const albumUserRepo = getRepository('albumUser'); | ||||
|       const userRepo = getRepository('user'); | ||||
|  | ||||
|       const user1 = mediumFactory.userInsert(); | ||||
|       await userRepo.create(user1); | ||||
|  | ||||
|       const album = mediumFactory.albumInsert({ ownerId: auth.user.id }); | ||||
|       await albumRepo.create(album, [], []); | ||||
|  | ||||
|       const albumUser = { albumsId: album.id, usersId: user1.id, role: AlbumUserRole.EDITOR }; | ||||
|       await albumUserRepo.create(albumUser); | ||||
|  | ||||
|       const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumUsersV1]); | ||||
|       const acks = [initialSyncResponse[0].ack]; | ||||
|       await sut.setAcks(auth, { acks }); | ||||
|  | ||||
|       await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]); | ||||
|  | ||||
|       await albumUserRepo.update({ albumsId: album.id, usersId: user1.id }, { role: AlbumUserRole.VIEWER }); | ||||
|  | ||||
|       await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: expect.objectContaining({ | ||||
|             albumId: albumUser.albumsId, | ||||
|             role: AlbumUserRole.VIEWER, | ||||
|             userId: albumUser.usersId, | ||||
|           }), | ||||
|           type: SyncEntityType.AlbumUserV1, | ||||
|         }, | ||||
|       ]); | ||||
|     }); | ||||
|  | ||||
|     it('should detect and sync a deleted shared user', async () => { | ||||
|       const { auth, testSync, getRepository, sut } = await setup(); | ||||
|  | ||||
|       const albumRepo = getRepository('album'); | ||||
|       const albumUserRepo = getRepository('albumUser'); | ||||
|       const userRepo = getRepository('user'); | ||||
|  | ||||
|       const user1 = mediumFactory.userInsert(); | ||||
|       await userRepo.create(user1); | ||||
|  | ||||
|       const album = mediumFactory.albumInsert({ ownerId: auth.user.id }); | ||||
|       await albumRepo.create(album, [], []); | ||||
|  | ||||
|       const albumUser = { albumsId: album.id, usersId: user1.id, role: AlbumUserRole.EDITOR }; | ||||
|       await albumUserRepo.create(albumUser); | ||||
|  | ||||
|       const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumUsersV1]); | ||||
|       const acks = [initialSyncResponse[0].ack]; | ||||
|       await sut.setAcks(auth, { acks }); | ||||
|  | ||||
|       await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]); | ||||
|  | ||||
|       await albumUserRepo.delete({ albumsId: album.id, usersId: user1.id }); | ||||
|  | ||||
|       await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: expect.objectContaining({ | ||||
|             albumId: albumUser.albumsId, | ||||
|             userId: albumUser.usersId, | ||||
|           }), | ||||
|           type: SyncEntityType.AlbumUserDeleteV1, | ||||
|         }, | ||||
|       ]); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('shared user', () => { | ||||
|     it('should detect and sync a new shared user', async () => { | ||||
|       const { auth, testSync, getRepository } = await setup(); | ||||
|  | ||||
|       const albumRepo = getRepository('album'); | ||||
|       const albumUserRepo = getRepository('albumUser'); | ||||
|       const userRepo = getRepository('user'); | ||||
|  | ||||
|       const user1 = mediumFactory.userInsert(); | ||||
|       await userRepo.create(user1); | ||||
|  | ||||
|       const album = mediumFactory.albumInsert({ ownerId: user1.id }); | ||||
|       await albumRepo.create(album, [], []); | ||||
|  | ||||
|       const albumUser = { albumsId: album.id, usersId: auth.user.id, role: AlbumUserRole.EDITOR }; | ||||
|       await albumUserRepo.create(albumUser); | ||||
|  | ||||
|       await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: expect.objectContaining({ | ||||
|             albumId: albumUser.albumsId, | ||||
|             role: albumUser.role, | ||||
|             userId: albumUser.usersId, | ||||
|           }), | ||||
|           type: SyncEntityType.AlbumUserV1, | ||||
|         }, | ||||
|       ]); | ||||
|     }); | ||||
|  | ||||
|     it('should detect and sync an updated shared user', async () => { | ||||
|       const { auth, testSync, getRepository, sut } = await setup(); | ||||
|  | ||||
|       const albumRepo = getRepository('album'); | ||||
|       const albumUserRepo = getRepository('albumUser'); | ||||
|       const userRepo = getRepository('user'); | ||||
|  | ||||
|       const owner = mediumFactory.userInsert(); | ||||
|       const user = mediumFactory.userInsert(); | ||||
|       await Promise.all([userRepo.create(owner), userRepo.create(user)]); | ||||
|  | ||||
|       const album = mediumFactory.albumInsert({ ownerId: owner.id }); | ||||
|       await albumRepo.create( | ||||
|         album, | ||||
|         [], | ||||
|         [ | ||||
|           { userId: auth.user.id, role: AlbumUserRole.EDITOR }, | ||||
|           { userId: user.id, role: AlbumUserRole.EDITOR }, | ||||
|         ], | ||||
|       ); | ||||
|  | ||||
|       const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumUsersV1]); | ||||
|       expect(initialSyncResponse).toHaveLength(2); | ||||
|       const acks = [initialSyncResponse[1].ack]; | ||||
|       await sut.setAcks(auth, { acks }); | ||||
|  | ||||
|       await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]); | ||||
|  | ||||
|       await albumUserRepo.update({ albumsId: album.id, usersId: user.id }, { role: AlbumUserRole.VIEWER }); | ||||
|  | ||||
|       await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: expect.objectContaining({ | ||||
|             albumId: album.id, | ||||
|             role: AlbumUserRole.VIEWER, | ||||
|             userId: user.id, | ||||
|           }), | ||||
|           type: SyncEntityType.AlbumUserV1, | ||||
|         }, | ||||
|       ]); | ||||
|     }); | ||||
|  | ||||
|     it('should detect and sync a deleted shared user', async () => { | ||||
|       const { auth, testSync, getRepository, sut } = await setup(); | ||||
|  | ||||
|       const albumRepo = getRepository('album'); | ||||
|       const albumUserRepo = getRepository('albumUser'); | ||||
|       const userRepo = getRepository('user'); | ||||
|  | ||||
|       const owner = mediumFactory.userInsert(); | ||||
|       const user = mediumFactory.userInsert(); | ||||
|       await Promise.all([userRepo.create(owner), userRepo.create(user)]); | ||||
|  | ||||
|       const album = mediumFactory.albumInsert({ ownerId: owner.id }); | ||||
|       await albumRepo.create( | ||||
|         album, | ||||
|         [], | ||||
|         [ | ||||
|           { userId: auth.user.id, role: AlbumUserRole.EDITOR }, | ||||
|           { userId: user.id, role: AlbumUserRole.EDITOR }, | ||||
|         ], | ||||
|       ); | ||||
|  | ||||
|       const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumUsersV1]); | ||||
|       expect(initialSyncResponse).toHaveLength(2); | ||||
|       const acks = [initialSyncResponse[1].ack]; | ||||
|       await sut.setAcks(auth, { acks }); | ||||
|  | ||||
|       await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]); | ||||
|  | ||||
|       await albumUserRepo.delete({ albumsId: album.id, usersId: user.id }); | ||||
|  | ||||
|       await expect(testSync(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: expect.objectContaining({ | ||||
|             albumId: album.id, | ||||
|             userId: user.id, | ||||
|           }), | ||||
|           type: SyncEntityType.AlbumUserDeleteV1, | ||||
|         }, | ||||
|       ]); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										220
									
								
								server/test/medium/specs/sync/sync-album.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										220
									
								
								server/test/medium/specs/sync/sync-album.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,220 @@ | ||||
| import { Kysely } from 'kysely'; | ||||
| import { DB } from 'src/db'; | ||||
| import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum'; | ||||
| import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory'; | ||||
| import { getKyselyDB } from 'test/utils'; | ||||
|  | ||||
| let defaultDatabase: Kysely<DB>; | ||||
|  | ||||
| const setup = async (db?: Kysely<DB>) => { | ||||
|   const database = db || defaultDatabase; | ||||
|   const result = newSyncTest({ db: database }); | ||||
|   const { auth, create } = newSyncAuthUser(); | ||||
|   await create(database); | ||||
|   return { ...result, auth }; | ||||
| }; | ||||
|  | ||||
| beforeAll(async () => { | ||||
|   defaultDatabase = await getKyselyDB(); | ||||
| }); | ||||
|  | ||||
| describe(SyncRequestType.AlbumsV1, () => { | ||||
|   it('should sync an album with the correct properties', async () => { | ||||
|     const { auth, getRepository, testSync } = await setup(); | ||||
|     const albumRepo = getRepository('album'); | ||||
|     const album = mediumFactory.albumInsert({ ownerId: auth.user.id }); | ||||
|     await albumRepo.create(album, [], []); | ||||
|     await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([ | ||||
|       { | ||||
|         ack: expect.any(String), | ||||
|         data: expect.objectContaining({ | ||||
|           id: album.id, | ||||
|           name: album.albumName, | ||||
|           ownerId: album.ownerId, | ||||
|         }), | ||||
|         type: SyncEntityType.AlbumV1, | ||||
|       }, | ||||
|     ]); | ||||
|   }); | ||||
|  | ||||
|   it('should detect and sync a new album', async () => { | ||||
|     const { auth, getRepository, testSync } = await setup(); | ||||
|     const albumRepo = getRepository('album'); | ||||
|     const album = mediumFactory.albumInsert({ ownerId: auth.user.id }); | ||||
|     await albumRepo.create(album, [], []); | ||||
|     await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([ | ||||
|       { | ||||
|         ack: expect.any(String), | ||||
|         data: expect.objectContaining({ | ||||
|           id: album.id, | ||||
|         }), | ||||
|         type: SyncEntityType.AlbumV1, | ||||
|       }, | ||||
|     ]); | ||||
|   }); | ||||
|  | ||||
|   it('should detect and sync an album delete', async () => { | ||||
|     const { auth, getRepository, testSync } = await setup(); | ||||
|     const albumRepo = getRepository('album'); | ||||
|     const album = mediumFactory.albumInsert({ ownerId: auth.user.id }); | ||||
|     await albumRepo.create(album, [], []); | ||||
|     await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([ | ||||
|       { | ||||
|         ack: expect.any(String), | ||||
|         data: expect.objectContaining({ | ||||
|           id: album.id, | ||||
|         }), | ||||
|         type: SyncEntityType.AlbumV1, | ||||
|       }, | ||||
|     ]); | ||||
|  | ||||
|     await albumRepo.delete(album.id); | ||||
|     await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([ | ||||
|       { | ||||
|         ack: expect.any(String), | ||||
|         data: { | ||||
|           albumId: album.id, | ||||
|         }, | ||||
|         type: SyncEntityType.AlbumDeleteV1, | ||||
|       }, | ||||
|     ]); | ||||
|   }); | ||||
|  | ||||
|   describe('shared albums', () => { | ||||
|     it('should detect and sync an album create', async () => { | ||||
|       const { auth, getRepository, testSync } = await setup(); | ||||
|       const albumRepo = getRepository('album'); | ||||
|       const userRepo = getRepository('user'); | ||||
|  | ||||
|       const user2 = mediumFactory.userInsert(); | ||||
|       await userRepo.create(user2); | ||||
|  | ||||
|       const album = mediumFactory.albumInsert({ ownerId: user2.id }); | ||||
|       await albumRepo.create(album, [], [{ userId: auth.user.id, role: AlbumUserRole.EDITOR }]); | ||||
|  | ||||
|       await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: expect.objectContaining({ id: album.id }), | ||||
|           type: SyncEntityType.AlbumV1, | ||||
|         }, | ||||
|       ]); | ||||
|     }); | ||||
|  | ||||
|     it('should detect and sync an album share (share before sync)', async () => { | ||||
|       const { auth, getRepository, testSync } = await setup(); | ||||
|       const albumRepo = getRepository('album'); | ||||
|       const albumUserRepo = getRepository('albumUser'); | ||||
|       const userRepo = getRepository('user'); | ||||
|  | ||||
|       const user2 = mediumFactory.userInsert(); | ||||
|       await userRepo.create(user2); | ||||
|  | ||||
|       const album = mediumFactory.albumInsert({ ownerId: user2.id }); | ||||
|       await albumRepo.create(album, [], []); | ||||
|       await albumUserRepo.create({ usersId: auth.user.id, albumsId: album.id, role: AlbumUserRole.EDITOR }); | ||||
|  | ||||
|       await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: expect.objectContaining({ id: album.id }), | ||||
|           type: SyncEntityType.AlbumV1, | ||||
|         }, | ||||
|       ]); | ||||
|     }); | ||||
|  | ||||
|     it('should detect and sync an album share (share after sync)', async () => { | ||||
|       const { auth, getRepository, sut, testSync } = await setup(); | ||||
|       const albumRepo = getRepository('album'); | ||||
|       const albumUserRepo = getRepository('albumUser'); | ||||
|       const userRepo = getRepository('user'); | ||||
|  | ||||
|       const user2 = mediumFactory.userInsert(); | ||||
|       await userRepo.create(user2); | ||||
|  | ||||
|       const userAlbum = mediumFactory.albumInsert({ ownerId: auth.user.id }); | ||||
|       const user2Album = mediumFactory.albumInsert({ ownerId: user2.id }); | ||||
|       await Promise.all([albumRepo.create(user2Album, [], []), albumRepo.create(userAlbum, [], [])]); | ||||
|  | ||||
|       const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumsV1]); | ||||
|  | ||||
|       expect(initialSyncResponse).toEqual([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: expect.objectContaining({ id: userAlbum.id }), | ||||
|           type: SyncEntityType.AlbumV1, | ||||
|         }, | ||||
|       ]); | ||||
|  | ||||
|       const acks = [initialSyncResponse[0].ack]; | ||||
|       await sut.setAcks(auth, { acks }); | ||||
|  | ||||
|       await albumUserRepo.create({ usersId: auth.user.id, albumsId: user2Album.id, role: AlbumUserRole.EDITOR }); | ||||
|  | ||||
|       await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: expect.objectContaining({ id: user2Album.id }), | ||||
|           type: SyncEntityType.AlbumV1, | ||||
|         }, | ||||
|       ]); | ||||
|     }); | ||||
|  | ||||
|     it('should detect and sync an album delete`', async () => { | ||||
|       const { auth, getRepository, testSync, sut } = await setup(); | ||||
|       const albumRepo = getRepository('album'); | ||||
|       const userRepo = getRepository('user'); | ||||
|  | ||||
|       const user2 = mediumFactory.userInsert(); | ||||
|       await userRepo.create(user2); | ||||
|  | ||||
|       const album = mediumFactory.albumInsert({ ownerId: user2.id }); | ||||
|       await albumRepo.create(album, [], [{ userId: auth.user.id, role: AlbumUserRole.EDITOR }]); | ||||
|  | ||||
|       const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumsV1]); | ||||
|       const acks = [initialSyncResponse[0].ack]; | ||||
|       await sut.setAcks(auth, { acks }); | ||||
|  | ||||
|       await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]); | ||||
|  | ||||
|       await albumRepo.delete(album.id); | ||||
|  | ||||
|       await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: { albumId: album.id }, | ||||
|           type: SyncEntityType.AlbumDeleteV1, | ||||
|         }, | ||||
|       ]); | ||||
|     }); | ||||
|  | ||||
|     it('should detect and sync an album unshare as an album delete', async () => { | ||||
|       const { auth, getRepository, testSync, sut } = await setup(); | ||||
|       const albumRepo = getRepository('album'); | ||||
|       const albumUserRepo = getRepository('albumUser'); | ||||
|       const userRepo = getRepository('user'); | ||||
|  | ||||
|       const user2 = mediumFactory.userInsert(); | ||||
|       await userRepo.create(user2); | ||||
|  | ||||
|       const album = mediumFactory.albumInsert({ ownerId: user2.id }); | ||||
|       await albumRepo.create(album, [], [{ userId: auth.user.id, role: AlbumUserRole.EDITOR }]); | ||||
|  | ||||
|       const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumsV1]); | ||||
|       const acks = [initialSyncResponse[0].ack]; | ||||
|       await sut.setAcks(auth, { acks }); | ||||
|  | ||||
|       await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]); | ||||
|  | ||||
|       await albumUserRepo.delete({ albumsId: album.id, usersId: auth.user.id }); | ||||
|  | ||||
|       await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: { albumId: album.id }, | ||||
|           type: SyncEntityType.AlbumDeleteV1, | ||||
|         }, | ||||
|       ]); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										100
									
								
								server/test/medium/specs/sync/sync-asset-exif.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								server/test/medium/specs/sync/sync-asset-exif.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,100 @@ | ||||
| import { Kysely } from 'kysely'; | ||||
| import { DB } from 'src/db'; | ||||
| import { SyncEntityType, SyncRequestType } from 'src/enum'; | ||||
| import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory'; | ||||
| import { factory } from 'test/small.factory'; | ||||
| import { getKyselyDB } from 'test/utils'; | ||||
|  | ||||
| let defaultDatabase: Kysely<DB>; | ||||
|  | ||||
| const setup = async (db?: Kysely<DB>) => { | ||||
|   const database = db || defaultDatabase; | ||||
|   const result = newSyncTest({ db: database }); | ||||
|   const { auth, create } = newSyncAuthUser(); | ||||
|   await create(database); | ||||
|   return { ...result, auth }; | ||||
| }; | ||||
| beforeAll(async () => { | ||||
|   defaultDatabase = await getKyselyDB(); | ||||
| }); | ||||
|  | ||||
| describe.concurrent(SyncRequestType.AssetExifsV1, () => { | ||||
|   it('should detect and sync the first asset exif', async () => { | ||||
|     const { auth, sut, getRepository, testSync } = await setup(); | ||||
|  | ||||
|     const assetRepo = getRepository('asset'); | ||||
|     const asset = mediumFactory.assetInsert({ ownerId: auth.user.id }); | ||||
|     await assetRepo.create(asset); | ||||
|     await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' }); | ||||
|  | ||||
|     const initialSyncResponse = await testSync(auth, [SyncRequestType.AssetExifsV1]); | ||||
|  | ||||
|     expect(initialSyncResponse).toHaveLength(1); | ||||
|     expect(initialSyncResponse).toEqual( | ||||
|       expect.arrayContaining([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: { | ||||
|             assetId: asset.id, | ||||
|             city: null, | ||||
|             country: null, | ||||
|             dateTimeOriginal: null, | ||||
|             description: '', | ||||
|             exifImageHeight: null, | ||||
|             exifImageWidth: null, | ||||
|             exposureTime: null, | ||||
|             fNumber: null, | ||||
|             fileSizeInByte: null, | ||||
|             focalLength: null, | ||||
|             fps: null, | ||||
|             iso: null, | ||||
|             latitude: null, | ||||
|             lensModel: null, | ||||
|             longitude: null, | ||||
|             make: 'Canon', | ||||
|             model: null, | ||||
|             modifyDate: null, | ||||
|             orientation: null, | ||||
|             profileDescription: null, | ||||
|             projectionType: null, | ||||
|             rating: null, | ||||
|             state: null, | ||||
|             timeZone: null, | ||||
|           }, | ||||
|           type: SyncEntityType.AssetExifV1, | ||||
|         }, | ||||
|       ]), | ||||
|     ); | ||||
|  | ||||
|     const acks = [initialSyncResponse[0].ack]; | ||||
|     await sut.setAcks(auth, { acks }); | ||||
|  | ||||
|     const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetExifsV1]); | ||||
|  | ||||
|     expect(ackSyncResponse).toHaveLength(0); | ||||
|   }); | ||||
|  | ||||
|   it('should only sync asset exif for own user', async () => { | ||||
|     const { auth, getRepository, testSync } = await setup(); | ||||
|  | ||||
|     const userRepo = getRepository('user'); | ||||
|     const user2 = mediumFactory.userInsert(); | ||||
|     await userRepo.create(user2); | ||||
|  | ||||
|     const partnerRepo = getRepository('partner'); | ||||
|     await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id }); | ||||
|  | ||||
|     const assetRepo = getRepository('asset'); | ||||
|     const asset = mediumFactory.assetInsert({ ownerId: user2.id }); | ||||
|     await assetRepo.create(asset); | ||||
|     await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' }); | ||||
|  | ||||
|     const sessionRepo = getRepository('session'); | ||||
|     const session = mediumFactory.sessionInsert({ userId: user2.id }); | ||||
|     await sessionRepo.create(session); | ||||
|  | ||||
|     const auth2 = factory.auth({ session, user: user2 }); | ||||
|     await expect(testSync(auth2, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1); | ||||
|     await expect(testSync(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(0); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										130
									
								
								server/test/medium/specs/sync/sync-asset.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								server/test/medium/specs/sync/sync-asset.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,130 @@ | ||||
| import { Kysely } from 'kysely'; | ||||
| import { DB } from 'src/db'; | ||||
| import { SyncEntityType, SyncRequestType } from 'src/enum'; | ||||
| import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory'; | ||||
| import { factory } from 'test/small.factory'; | ||||
| import { getKyselyDB } from 'test/utils'; | ||||
|  | ||||
| let defaultDatabase: Kysely<DB>; | ||||
|  | ||||
| const setup = async (db?: Kysely<DB>) => { | ||||
|   const database = db || defaultDatabase; | ||||
|   const result = newSyncTest({ db: database }); | ||||
|   const { auth, create } = newSyncAuthUser(); | ||||
|   await create(database); | ||||
|   return { ...result, auth }; | ||||
| }; | ||||
|  | ||||
| beforeAll(async () => { | ||||
|   defaultDatabase = await getKyselyDB(); | ||||
| }); | ||||
|  | ||||
| describe.concurrent(SyncEntityType.AssetV1, () => { | ||||
|   it('should detect and sync the first asset', async () => { | ||||
|     const { auth, sut, getRepository, testSync } = await setup(); | ||||
|  | ||||
|     const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA='; | ||||
|     const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA='; | ||||
|     const date = new Date().toISOString(); | ||||
|  | ||||
|     const assetRepo = getRepository('asset'); | ||||
|     const asset = mediumFactory.assetInsert({ | ||||
|       ownerId: auth.user.id, | ||||
|       checksum: Buffer.from(checksum, 'base64'), | ||||
|       thumbhash: Buffer.from(thumbhash, 'base64'), | ||||
|       fileCreatedAt: date, | ||||
|       fileModifiedAt: date, | ||||
|       localDateTime: date, | ||||
|       deletedAt: null, | ||||
|     }); | ||||
|     await assetRepo.create(asset); | ||||
|  | ||||
|     const initialSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]); | ||||
|  | ||||
|     expect(initialSyncResponse).toHaveLength(1); | ||||
|     expect(initialSyncResponse).toEqual( | ||||
|       expect.arrayContaining([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: { | ||||
|             id: asset.id, | ||||
|             ownerId: asset.ownerId, | ||||
|             thumbhash, | ||||
|             checksum, | ||||
|             deletedAt: asset.deletedAt, | ||||
|             fileCreatedAt: asset.fileCreatedAt, | ||||
|             fileModifiedAt: asset.fileModifiedAt, | ||||
|             isFavorite: asset.isFavorite, | ||||
|             localDateTime: asset.localDateTime, | ||||
|             type: asset.type, | ||||
|             visibility: asset.visibility, | ||||
|           }, | ||||
|           type: 'AssetV1', | ||||
|         }, | ||||
|       ]), | ||||
|     ); | ||||
|  | ||||
|     const acks = [initialSyncResponse[0].ack]; | ||||
|     await sut.setAcks(auth, { acks }); | ||||
|  | ||||
|     const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]); | ||||
|  | ||||
|     expect(ackSyncResponse).toHaveLength(0); | ||||
|   }); | ||||
|  | ||||
|   it('should detect and sync a deleted asset', async () => { | ||||
|     const { auth, sut, getRepository, testSync } = await setup(); | ||||
|  | ||||
|     const assetRepo = getRepository('asset'); | ||||
|     const asset = mediumFactory.assetInsert({ ownerId: auth.user.id }); | ||||
|     await assetRepo.create(asset); | ||||
|     await assetRepo.remove(asset); | ||||
|  | ||||
|     const response = await testSync(auth, [SyncRequestType.AssetsV1]); | ||||
|  | ||||
|     expect(response).toHaveLength(1); | ||||
|     expect(response).toEqual( | ||||
|       expect.arrayContaining([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: { | ||||
|             assetId: asset.id, | ||||
|           }, | ||||
|           type: 'AssetDeleteV1', | ||||
|         }, | ||||
|       ]), | ||||
|     ); | ||||
|  | ||||
|     const acks = response.map(({ ack }) => ack); | ||||
|     await sut.setAcks(auth, { acks }); | ||||
|  | ||||
|     const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]); | ||||
|  | ||||
|     expect(ackSyncResponse).toHaveLength(0); | ||||
|   }); | ||||
|  | ||||
|   it('should not sync an asset or asset delete for an unrelated user', async () => { | ||||
|     const { auth, getRepository, testSync } = await setup(); | ||||
|  | ||||
|     const userRepo = getRepository('user'); | ||||
|     const user2 = mediumFactory.userInsert(); | ||||
|     await userRepo.create(user2); | ||||
|  | ||||
|     const sessionRepo = getRepository('session'); | ||||
|     const session = mediumFactory.sessionInsert({ userId: user2.id }); | ||||
|     await sessionRepo.create(session); | ||||
|  | ||||
|     const assetRepo = getRepository('asset'); | ||||
|     const asset = mediumFactory.assetInsert({ ownerId: user2.id }); | ||||
|     await assetRepo.create(asset); | ||||
|  | ||||
|     const auth2 = factory.auth({ session, user: user2 }); | ||||
|  | ||||
|     expect(await testSync(auth2, [SyncRequestType.AssetsV1])).toHaveLength(1); | ||||
|     expect(await testSync(auth, [SyncRequestType.AssetsV1])).toHaveLength(0); | ||||
|  | ||||
|     await assetRepo.remove(asset); | ||||
|     expect(await testSync(auth2, [SyncRequestType.AssetsV1])).toHaveLength(1); | ||||
|     expect(await testSync(auth, [SyncRequestType.AssetsV1])).toHaveLength(0); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										129
									
								
								server/test/medium/specs/sync/sync-partner-asset-exif.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								server/test/medium/specs/sync/sync-partner-asset-exif.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,129 @@ | ||||
| import { Kysely } from 'kysely'; | ||||
| import { DB } from 'src/db'; | ||||
| import { SyncEntityType, SyncRequestType } from 'src/enum'; | ||||
| import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory'; | ||||
| import { factory } from 'test/small.factory'; | ||||
| import { getKyselyDB } from 'test/utils'; | ||||
|  | ||||
| let defaultDatabase: Kysely<DB>; | ||||
|  | ||||
| const setup = async (db?: Kysely<DB>) => { | ||||
|   const database = db || defaultDatabase; | ||||
|   const result = newSyncTest({ db: database }); | ||||
|   const { auth, create } = newSyncAuthUser(); | ||||
|   await create(database); | ||||
|   return { ...result, auth }; | ||||
| }; | ||||
|  | ||||
| beforeAll(async () => { | ||||
|   defaultDatabase = await getKyselyDB(); | ||||
| }); | ||||
|  | ||||
| describe.concurrent(SyncRequestType.PartnerAssetExifsV1, () => { | ||||
|   it('should detect and sync the first partner asset exif', async () => { | ||||
|     const { auth, sut, getRepository, testSync } = await setup(); | ||||
|  | ||||
|     const userRepo = getRepository('user'); | ||||
|     const user2 = mediumFactory.userInsert(); | ||||
|     await userRepo.create(user2); | ||||
|  | ||||
|     const partnerRepo = getRepository('partner'); | ||||
|     await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id }); | ||||
|  | ||||
|     const assetRepo = getRepository('asset'); | ||||
|     const asset = mediumFactory.assetInsert({ ownerId: user2.id }); | ||||
|     await assetRepo.create(asset); | ||||
|     await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' }); | ||||
|  | ||||
|     const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]); | ||||
|  | ||||
|     expect(initialSyncResponse).toHaveLength(1); | ||||
|     expect(initialSyncResponse).toEqual( | ||||
|       expect.arrayContaining([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: { | ||||
|             assetId: asset.id, | ||||
|             city: null, | ||||
|             country: null, | ||||
|             dateTimeOriginal: null, | ||||
|             description: '', | ||||
|             exifImageHeight: null, | ||||
|             exifImageWidth: null, | ||||
|             exposureTime: null, | ||||
|             fNumber: null, | ||||
|             fileSizeInByte: null, | ||||
|             focalLength: null, | ||||
|             fps: null, | ||||
|             iso: null, | ||||
|             latitude: null, | ||||
|             lensModel: null, | ||||
|             longitude: null, | ||||
|             make: 'Canon', | ||||
|             model: null, | ||||
|             modifyDate: null, | ||||
|             orientation: null, | ||||
|             profileDescription: null, | ||||
|             projectionType: null, | ||||
|             rating: null, | ||||
|             state: null, | ||||
|             timeZone: null, | ||||
|           }, | ||||
|           type: SyncEntityType.PartnerAssetExifV1, | ||||
|         }, | ||||
|       ]), | ||||
|     ); | ||||
|  | ||||
|     const acks = [initialSyncResponse[0].ack]; | ||||
|     await sut.setAcks(auth, { acks }); | ||||
|  | ||||
|     const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]); | ||||
|  | ||||
|     expect(ackSyncResponse).toHaveLength(0); | ||||
|   }); | ||||
|  | ||||
|   it('should not sync partner asset exif for own user', async () => { | ||||
|     const { auth, getRepository, testSync } = await setup(); | ||||
|  | ||||
|     const userRepo = getRepository('user'); | ||||
|     const user2 = mediumFactory.userInsert(); | ||||
|     await userRepo.create(user2); | ||||
|  | ||||
|     const partnerRepo = getRepository('partner'); | ||||
|     await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id }); | ||||
|  | ||||
|     const assetRepo = getRepository('asset'); | ||||
|     const asset = mediumFactory.assetInsert({ ownerId: auth.user.id }); | ||||
|     await assetRepo.create(asset); | ||||
|     await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' }); | ||||
|  | ||||
|     await expect(testSync(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1); | ||||
|     await expect(testSync(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0); | ||||
|   }); | ||||
|  | ||||
|   it('should not sync partner asset exif for unrelated user', async () => { | ||||
|     const { auth, getRepository, testSync } = await setup(); | ||||
|  | ||||
|     const userRepo = getRepository('user'); | ||||
|  | ||||
|     const user2 = mediumFactory.userInsert(); | ||||
|     const user3 = mediumFactory.userInsert(); | ||||
|     await Promise.all([userRepo.create(user2), userRepo.create(user3)]); | ||||
|  | ||||
|     const partnerRepo = getRepository('partner'); | ||||
|     await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id }); | ||||
|  | ||||
|     const assetRepo = getRepository('asset'); | ||||
|     const asset = mediumFactory.assetInsert({ ownerId: user3.id }); | ||||
|     await assetRepo.create(asset); | ||||
|     await assetRepo.upsertExif({ assetId: asset.id, make: 'Canon' }); | ||||
|  | ||||
|     const sessionRepo = getRepository('session'); | ||||
|     const session = mediumFactory.sessionInsert({ userId: user3.id }); | ||||
|     await sessionRepo.create(session); | ||||
|  | ||||
|     const authUser3 = factory.auth({ session, user: user3 }); | ||||
|     await expect(testSync(authUser3, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1); | ||||
|     await expect(testSync(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										208
									
								
								server/test/medium/specs/sync/sync-partner-asset.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										208
									
								
								server/test/medium/specs/sync/sync-partner-asset.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,208 @@ | ||||
| import { Kysely } from 'kysely'; | ||||
| import { DB } from 'src/db'; | ||||
| import { SyncEntityType, SyncRequestType } from 'src/enum'; | ||||
| import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory'; | ||||
| import { factory } from 'test/small.factory'; | ||||
| import { getKyselyDB } from 'test/utils'; | ||||
|  | ||||
| let defaultDatabase: Kysely<DB>; | ||||
|  | ||||
| const setup = async (db?: Kysely<DB>) => { | ||||
|   const database = db || defaultDatabase; | ||||
|   const result = newSyncTest({ db: database }); | ||||
|   const { auth, create } = newSyncAuthUser(); | ||||
|   await create(database); | ||||
|   return { ...result, auth }; | ||||
| }; | ||||
|  | ||||
| beforeAll(async () => { | ||||
|   defaultDatabase = await getKyselyDB(); | ||||
| }); | ||||
|  | ||||
| describe.concurrent(SyncRequestType.PartnerAssetsV1, () => { | ||||
|   it('should detect and sync the first partner asset', async () => { | ||||
|     const { auth, sut, getRepository, testSync } = await setup(); | ||||
|  | ||||
|     const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA='; | ||||
|     const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA='; | ||||
|     const date = new Date().toISOString(); | ||||
|  | ||||
|     const userRepo = getRepository('user'); | ||||
|     const user2 = mediumFactory.userInsert(); | ||||
|     await userRepo.create(user2); | ||||
|  | ||||
|     const assetRepo = getRepository('asset'); | ||||
|     const asset = mediumFactory.assetInsert({ | ||||
|       ownerId: user2.id, | ||||
|       checksum: Buffer.from(checksum, 'base64'), | ||||
|       thumbhash: Buffer.from(thumbhash, 'base64'), | ||||
|       fileCreatedAt: date, | ||||
|       fileModifiedAt: date, | ||||
|       localDateTime: date, | ||||
|       deletedAt: null, | ||||
|     }); | ||||
|     await assetRepo.create(asset); | ||||
|  | ||||
|     const partnerRepo = getRepository('partner'); | ||||
|     await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id }); | ||||
|  | ||||
|     const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); | ||||
|  | ||||
|     expect(initialSyncResponse).toHaveLength(1); | ||||
|     expect(initialSyncResponse).toEqual( | ||||
|       expect.arrayContaining([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: { | ||||
|             id: asset.id, | ||||
|             ownerId: asset.ownerId, | ||||
|             thumbhash, | ||||
|             checksum, | ||||
|             deletedAt: null, | ||||
|             fileCreatedAt: date, | ||||
|             fileModifiedAt: date, | ||||
|             isFavorite: false, | ||||
|             localDateTime: date, | ||||
|             type: asset.type, | ||||
|             visibility: asset.visibility, | ||||
|           }, | ||||
|           type: SyncEntityType.PartnerAssetV1, | ||||
|         }, | ||||
|       ]), | ||||
|     ); | ||||
|  | ||||
|     const acks = [initialSyncResponse[0].ack]; | ||||
|     await sut.setAcks(auth, { acks }); | ||||
|  | ||||
|     const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); | ||||
|  | ||||
|     expect(ackSyncResponse).toHaveLength(0); | ||||
|   }); | ||||
|  | ||||
|   it('should detect and sync a deleted partner asset', async () => { | ||||
|     const { auth, sut, getRepository, testSync } = await setup(); | ||||
|  | ||||
|     const userRepo = getRepository('user'); | ||||
|     const user2 = mediumFactory.userInsert(); | ||||
|     await userRepo.create(user2); | ||||
|     const asset = mediumFactory.assetInsert({ ownerId: user2.id }); | ||||
|  | ||||
|     const assetRepo = getRepository('asset'); | ||||
|     await assetRepo.create(asset); | ||||
|  | ||||
|     const partnerRepo = getRepository('partner'); | ||||
|     await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id }); | ||||
|     await assetRepo.remove(asset); | ||||
|  | ||||
|     const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); | ||||
|  | ||||
|     expect(response).toHaveLength(1); | ||||
|     expect(response).toEqual( | ||||
|       expect.arrayContaining([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: { | ||||
|             assetId: asset.id, | ||||
|           }, | ||||
|           type: SyncEntityType.PartnerAssetDeleteV1, | ||||
|         }, | ||||
|       ]), | ||||
|     ); | ||||
|  | ||||
|     const acks = response.map(({ ack }) => ack); | ||||
|     await sut.setAcks(auth, { acks }); | ||||
|  | ||||
|     const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); | ||||
|  | ||||
|     expect(ackSyncResponse).toHaveLength(0); | ||||
|   }); | ||||
|  | ||||
|   it('should not sync a deleted partner asset due to a user delete', async () => { | ||||
|     const { auth, getRepository, testSync } = await setup(); | ||||
|  | ||||
|     const userRepo = getRepository('user'); | ||||
|     const user2 = mediumFactory.userInsert(); | ||||
|     await userRepo.create(user2); | ||||
|  | ||||
|     const partnerRepo = getRepository('partner'); | ||||
|     await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id }); | ||||
|  | ||||
|     const assetRepo = getRepository('asset'); | ||||
|     await assetRepo.create(mediumFactory.assetInsert({ ownerId: user2.id })); | ||||
|  | ||||
|     await userRepo.delete({ id: user2.id }, true); | ||||
|  | ||||
|     const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]); | ||||
|     expect(response).toHaveLength(0); | ||||
|   }); | ||||
|  | ||||
|   it('should not sync a deleted partner asset due to a partner delete (unshare)', async () => { | ||||
|     const { auth, getRepository, testSync } = await setup(); | ||||
|  | ||||
|     const userRepo = getRepository('user'); | ||||
|     const user2 = mediumFactory.userInsert(); | ||||
|     await userRepo.create(user2); | ||||
|  | ||||
|     const assetRepo = getRepository('asset'); | ||||
|     await assetRepo.create(mediumFactory.assetInsert({ ownerId: user2.id })); | ||||
|  | ||||
|     const partnerRepo = getRepository('partner'); | ||||
|     const partner = { sharedById: user2.id, sharedWithId: auth.user.id }; | ||||
|     await partnerRepo.create(partner); | ||||
|  | ||||
|     await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(1); | ||||
|  | ||||
|     await partnerRepo.remove(partner); | ||||
|  | ||||
|     await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0); | ||||
|   }); | ||||
|  | ||||
|   it('should not sync an asset or asset delete for own user', async () => { | ||||
|     const { auth, getRepository, testSync } = await setup(); | ||||
|  | ||||
|     const userRepo = getRepository('user'); | ||||
|     const user2 = mediumFactory.userInsert(); | ||||
|     await userRepo.create(user2); | ||||
|  | ||||
|     const assetRepo = getRepository('asset'); | ||||
|     const asset = mediumFactory.assetInsert({ ownerId: auth.user.id }); | ||||
|     await assetRepo.create(asset); | ||||
|  | ||||
|     const partnerRepo = getRepository('partner'); | ||||
|     await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id }); | ||||
|  | ||||
|     await expect(testSync(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1); | ||||
|     await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0); | ||||
|  | ||||
|     await assetRepo.remove(asset); | ||||
|  | ||||
|     await expect(testSync(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1); | ||||
|     await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0); | ||||
|   }); | ||||
|  | ||||
|   it('should not sync an asset or asset delete for unrelated user', async () => { | ||||
|     const { auth, getRepository, testSync } = await setup(); | ||||
|  | ||||
|     const userRepo = getRepository('user'); | ||||
|     const user2 = mediumFactory.userInsert(); | ||||
|     await userRepo.create(user2); | ||||
|  | ||||
|     const sessionRepo = getRepository('session'); | ||||
|     const session = mediumFactory.sessionInsert({ userId: user2.id }); | ||||
|     await sessionRepo.create(session); | ||||
|  | ||||
|     const auth2 = factory.auth({ session, user: user2 }); | ||||
|  | ||||
|     const assetRepo = getRepository('asset'); | ||||
|     const asset = mediumFactory.assetInsert({ ownerId: user2.id }); | ||||
|     await assetRepo.create(asset); | ||||
|  | ||||
|     await expect(testSync(auth2, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1); | ||||
|     await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0); | ||||
|  | ||||
|     await assetRepo.remove(asset); | ||||
|  | ||||
|     await expect(testSync(auth2, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1); | ||||
|     await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										221
									
								
								server/test/medium/specs/sync/sync-partner.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										221
									
								
								server/test/medium/specs/sync/sync-partner.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,221 @@ | ||||
| import { Kysely } from 'kysely'; | ||||
| import { DB } from 'src/db'; | ||||
| import { SyncEntityType, SyncRequestType } from 'src/enum'; | ||||
| import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory'; | ||||
| import { getKyselyDB } from 'test/utils'; | ||||
|  | ||||
| let defaultDatabase: Kysely<DB>; | ||||
|  | ||||
| const setup = async (db?: Kysely<DB>) => { | ||||
|   const database = db || defaultDatabase; | ||||
|   const result = newSyncTest({ db: database }); | ||||
|   const { auth, create } = newSyncAuthUser(); | ||||
|   await create(database); | ||||
|   return { ...result, auth }; | ||||
| }; | ||||
|  | ||||
| beforeAll(async () => { | ||||
|   defaultDatabase = await getKyselyDB(); | ||||
| }); | ||||
|  | ||||
| describe.concurrent(SyncEntityType.PartnerV1, () => { | ||||
|   it('should detect and sync the first partner', async () => { | ||||
|     const { auth, sut, getRepository, testSync } = await setup(); | ||||
|  | ||||
|     const user1 = auth.user; | ||||
|     const userRepo = getRepository('user'); | ||||
|     const partnerRepo = getRepository('partner'); | ||||
|  | ||||
|     const user2 = mediumFactory.userInsert(); | ||||
|     await userRepo.create(user2); | ||||
|  | ||||
|     const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user1.id }); | ||||
|  | ||||
|     const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); | ||||
|  | ||||
|     expect(initialSyncResponse).toHaveLength(1); | ||||
|     expect(initialSyncResponse).toEqual( | ||||
|       expect.arrayContaining([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: { | ||||
|             inTimeline: partner.inTimeline, | ||||
|             sharedById: partner.sharedById, | ||||
|             sharedWithId: partner.sharedWithId, | ||||
|           }, | ||||
|           type: 'PartnerV1', | ||||
|         }, | ||||
|       ]), | ||||
|     ); | ||||
|  | ||||
|     const acks = [initialSyncResponse[0].ack]; | ||||
|     await sut.setAcks(auth, { acks }); | ||||
|  | ||||
|     const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); | ||||
|  | ||||
|     expect(ackSyncResponse).toHaveLength(0); | ||||
|   }); | ||||
|  | ||||
|   it('should detect and sync a deleted partner', async () => { | ||||
|     const { auth, sut, getRepository, testSync } = await setup(); | ||||
|  | ||||
|     const userRepo = getRepository('user'); | ||||
|     const user1 = auth.user; | ||||
|     const user2 = mediumFactory.userInsert(); | ||||
|     await userRepo.create(user2); | ||||
|  | ||||
|     const partnerRepo = getRepository('partner'); | ||||
|     const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user1.id }); | ||||
|     await partnerRepo.remove(partner); | ||||
|  | ||||
|     const response = await testSync(auth, [SyncRequestType.PartnersV1]); | ||||
|  | ||||
|     expect(response).toHaveLength(1); | ||||
|     expect(response).toEqual( | ||||
|       expect.arrayContaining([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: { | ||||
|             sharedById: partner.sharedById, | ||||
|             sharedWithId: partner.sharedWithId, | ||||
|           }, | ||||
|           type: 'PartnerDeleteV1', | ||||
|         }, | ||||
|       ]), | ||||
|     ); | ||||
|  | ||||
|     const acks = response.map(({ ack }) => ack); | ||||
|     await sut.setAcks(auth, { acks }); | ||||
|  | ||||
|     const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); | ||||
|  | ||||
|     expect(ackSyncResponse).toHaveLength(0); | ||||
|   }); | ||||
|  | ||||
|   it('should detect and sync a partner share both to and from another user', async () => { | ||||
|     const { auth, sut, getRepository, testSync } = await setup(); | ||||
|  | ||||
|     const userRepo = getRepository('user'); | ||||
|     const user1 = auth.user; | ||||
|     const user2 = await userRepo.create(mediumFactory.userInsert()); | ||||
|  | ||||
|     const partnerRepo = getRepository('partner'); | ||||
|     const partner1 = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user1.id }); | ||||
|     const partner2 = await partnerRepo.create({ sharedById: user1.id, sharedWithId: user2.id }); | ||||
|  | ||||
|     const response = await testSync(auth, [SyncRequestType.PartnersV1]); | ||||
|  | ||||
|     expect(response).toHaveLength(2); | ||||
|     expect(response).toEqual( | ||||
|       expect.arrayContaining([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: { | ||||
|             inTimeline: partner1.inTimeline, | ||||
|             sharedById: partner1.sharedById, | ||||
|             sharedWithId: partner1.sharedWithId, | ||||
|           }, | ||||
|           type: 'PartnerV1', | ||||
|         }, | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: { | ||||
|             inTimeline: partner2.inTimeline, | ||||
|             sharedById: partner2.sharedById, | ||||
|             sharedWithId: partner2.sharedWithId, | ||||
|           }, | ||||
|           type: 'PartnerV1', | ||||
|         }, | ||||
|       ]), | ||||
|     ); | ||||
|  | ||||
|     await sut.setAcks(auth, { acks: [response[1].ack] }); | ||||
|  | ||||
|     const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); | ||||
|  | ||||
|     expect(ackSyncResponse).toHaveLength(0); | ||||
|   }); | ||||
|  | ||||
|   it('should sync a partner and then an update to that same partner', async () => { | ||||
|     const { auth, sut, getRepository, testSync } = await setup(); | ||||
|  | ||||
|     const userRepo = getRepository('user'); | ||||
|     const user1 = auth.user; | ||||
|     const user2 = await userRepo.create(mediumFactory.userInsert()); | ||||
|  | ||||
|     const partnerRepo = getRepository('partner'); | ||||
|     const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user1.id }); | ||||
|  | ||||
|     const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); | ||||
|  | ||||
|     expect(initialSyncResponse).toHaveLength(1); | ||||
|     expect(initialSyncResponse).toEqual( | ||||
|       expect.arrayContaining([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: { | ||||
|             inTimeline: partner.inTimeline, | ||||
|             sharedById: partner.sharedById, | ||||
|             sharedWithId: partner.sharedWithId, | ||||
|           }, | ||||
|           type: 'PartnerV1', | ||||
|         }, | ||||
|       ]), | ||||
|     ); | ||||
|  | ||||
|     const acks = [initialSyncResponse[0].ack]; | ||||
|     await sut.setAcks(auth, { acks }); | ||||
|  | ||||
|     const updated = await partnerRepo.update( | ||||
|       { sharedById: partner.sharedById, sharedWithId: partner.sharedWithId }, | ||||
|       { inTimeline: true }, | ||||
|     ); | ||||
|  | ||||
|     const updatedSyncResponse = await testSync(auth, [SyncRequestType.PartnersV1]); | ||||
|  | ||||
|     expect(updatedSyncResponse).toHaveLength(1); | ||||
|     expect(updatedSyncResponse).toEqual( | ||||
|       expect.arrayContaining([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: { | ||||
|             inTimeline: updated.inTimeline, | ||||
|             sharedById: updated.sharedById, | ||||
|             sharedWithId: updated.sharedWithId, | ||||
|           }, | ||||
|           type: 'PartnerV1', | ||||
|         }, | ||||
|       ]), | ||||
|     ); | ||||
|   }); | ||||
|  | ||||
|   it('should not sync a partner or partner delete for an unrelated user', async () => { | ||||
|     const { auth, getRepository, testSync } = await setup(); | ||||
|  | ||||
|     const userRepo = getRepository('user'); | ||||
|     const user2 = await userRepo.create(mediumFactory.userInsert()); | ||||
|     const user3 = await userRepo.create(mediumFactory.userInsert()); | ||||
|  | ||||
|     const partnerRepo = getRepository('partner'); | ||||
|     const partner = await partnerRepo.create({ sharedById: user2.id, sharedWithId: user3.id }); | ||||
|  | ||||
|     expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0); | ||||
|  | ||||
|     await partnerRepo.remove(partner); | ||||
|  | ||||
|     expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0); | ||||
|   }); | ||||
|  | ||||
|   it('should not sync a partner delete after a user is deleted', async () => { | ||||
|     const { auth, getRepository, testSync } = await setup(); | ||||
|  | ||||
|     const userRepo = getRepository('user'); | ||||
|     const user2 = await userRepo.create(mediumFactory.userInsert()); | ||||
|  | ||||
|     const partnerRepo = getRepository('partner'); | ||||
|     await partnerRepo.create({ sharedById: user2.id, sharedWithId: auth.user.id }); | ||||
|     await userRepo.delete({ id: user2.id }, true); | ||||
|  | ||||
|     expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										12
									
								
								server/test/medium/specs/sync/sync-types.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								server/test/medium/specs/sync/sync-types.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| import { SyncRequestType } from 'src/enum'; | ||||
| import { SYNC_TYPES_ORDER } from 'src/services/sync.service'; | ||||
|  | ||||
| describe('types', () => { | ||||
|   it('should have all the types in the ordering variable', () => { | ||||
|     for (const key in SyncRequestType) { | ||||
|       expect(SYNC_TYPES_ORDER).includes(key); | ||||
|     } | ||||
|  | ||||
|     expect(SYNC_TYPES_ORDER.length).toBe(Object.keys(SyncRequestType).length); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										179
									
								
								server/test/medium/specs/sync/sync-user.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								server/test/medium/specs/sync/sync-user.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,179 @@ | ||||
| import { Kysely } from 'kysely'; | ||||
| import { DB } from 'src/db'; | ||||
| import { SyncEntityType, SyncRequestType } from 'src/enum'; | ||||
| import { mediumFactory, newSyncAuthUser, newSyncTest } from 'test/medium.factory'; | ||||
| import { getKyselyDB } from 'test/utils'; | ||||
|  | ||||
| let defaultDatabase: Kysely<DB>; | ||||
|  | ||||
| const setup = async (db?: Kysely<DB>) => { | ||||
|   const database = db || defaultDatabase; | ||||
|   const result = newSyncTest({ db: database }); | ||||
|   const { auth, create } = newSyncAuthUser(); | ||||
|   await create(database); | ||||
|   return { ...result, auth }; | ||||
| }; | ||||
|  | ||||
| beforeAll(async () => { | ||||
|   defaultDatabase = await getKyselyDB(); | ||||
| }); | ||||
|  | ||||
| describe.concurrent(SyncEntityType.UserV1, () => { | ||||
|   it('should detect and sync the first user', async () => { | ||||
|     const { auth, sut, getRepository, testSync } = await setup(await getKyselyDB()); | ||||
|  | ||||
|     const userRepo = getRepository('user'); | ||||
|     const user = await userRepo.get(auth.user.id, { withDeleted: false }); | ||||
|     if (!user) { | ||||
|       expect.fail('First user should exist'); | ||||
|     } | ||||
|  | ||||
|     const initialSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); | ||||
|     expect(initialSyncResponse).toHaveLength(1); | ||||
|     expect(initialSyncResponse).toEqual([ | ||||
|       { | ||||
|         ack: expect.any(String), | ||||
|         data: { | ||||
|           deletedAt: user.deletedAt, | ||||
|           email: user.email, | ||||
|           id: user.id, | ||||
|           name: user.name, | ||||
|         }, | ||||
|         type: 'UserV1', | ||||
|       }, | ||||
|     ]); | ||||
|  | ||||
|     const acks = [initialSyncResponse[0].ack]; | ||||
|     await sut.setAcks(auth, { acks }); | ||||
|     const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); | ||||
|  | ||||
|     expect(ackSyncResponse).toHaveLength(0); | ||||
|   }); | ||||
|  | ||||
|   it('should detect and sync a soft deleted user', async () => { | ||||
|     const { auth, sut, getRepository, testSync } = await setup(await getKyselyDB()); | ||||
|  | ||||
|     const deletedAt = new Date().toISOString(); | ||||
|     const deletedUser = mediumFactory.userInsert({ deletedAt }); | ||||
|     const deleted = await getRepository('user').create(deletedUser); | ||||
|  | ||||
|     const response = await testSync(auth, [SyncRequestType.UsersV1]); | ||||
|  | ||||
|     expect(response).toHaveLength(2); | ||||
|     expect(response).toEqual( | ||||
|       expect.arrayContaining([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: { | ||||
|             deletedAt: null, | ||||
|             email: auth.user.email, | ||||
|             id: auth.user.id, | ||||
|             name: auth.user.name, | ||||
|           }, | ||||
|           type: 'UserV1', | ||||
|         }, | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: { | ||||
|             deletedAt, | ||||
|             email: deleted.email, | ||||
|             id: deleted.id, | ||||
|             name: deleted.name, | ||||
|           }, | ||||
|           type: 'UserV1', | ||||
|         }, | ||||
|       ]), | ||||
|     ); | ||||
|  | ||||
|     const acks = [response[1].ack]; | ||||
|     await sut.setAcks(auth, { acks }); | ||||
|     const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); | ||||
|  | ||||
|     expect(ackSyncResponse).toHaveLength(0); | ||||
|   }); | ||||
|  | ||||
|   it('should detect and sync a deleted user', async () => { | ||||
|     const { auth, sut, getRepository, testSync } = await setup(await getKyselyDB()); | ||||
|  | ||||
|     const userRepo = getRepository('user'); | ||||
|     const user = mediumFactory.userInsert(); | ||||
|     await userRepo.create(user); | ||||
|     await userRepo.delete({ id: user.id }, true); | ||||
|  | ||||
|     const response = await testSync(auth, [SyncRequestType.UsersV1]); | ||||
|  | ||||
|     expect(response).toHaveLength(2); | ||||
|     expect(response).toEqual( | ||||
|       expect.arrayContaining([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: { | ||||
|             userId: user.id, | ||||
|           }, | ||||
|           type: 'UserDeleteV1', | ||||
|         }, | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: { | ||||
|             deletedAt: null, | ||||
|             email: auth.user.email, | ||||
|             id: auth.user.id, | ||||
|             name: auth.user.name, | ||||
|           }, | ||||
|           type: 'UserV1', | ||||
|         }, | ||||
|       ]), | ||||
|     ); | ||||
|  | ||||
|     const acks = response.map(({ ack }) => ack); | ||||
|     await sut.setAcks(auth, { acks }); | ||||
|     const ackSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); | ||||
|  | ||||
|     expect(ackSyncResponse).toHaveLength(0); | ||||
|   }); | ||||
|  | ||||
|   it('should sync a user and then an update to that same user', async () => { | ||||
|     const { auth, sut, getRepository, testSync } = await setup(await getKyselyDB()); | ||||
|  | ||||
|     const initialSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); | ||||
|  | ||||
|     expect(initialSyncResponse).toHaveLength(1); | ||||
|     expect(initialSyncResponse).toEqual( | ||||
|       expect.arrayContaining([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: { | ||||
|             deletedAt: null, | ||||
|             email: auth.user.email, | ||||
|             id: auth.user.id, | ||||
|             name: auth.user.name, | ||||
|           }, | ||||
|           type: 'UserV1', | ||||
|         }, | ||||
|       ]), | ||||
|     ); | ||||
|  | ||||
|     const acks = [initialSyncResponse[0].ack]; | ||||
|     await sut.setAcks(auth, { acks }); | ||||
|  | ||||
|     const userRepo = getRepository('user'); | ||||
|     const updated = await userRepo.update(auth.user.id, { name: 'new name' }); | ||||
|     const updatedSyncResponse = await testSync(auth, [SyncRequestType.UsersV1]); | ||||
|  | ||||
|     expect(updatedSyncResponse).toHaveLength(1); | ||||
|     expect(updatedSyncResponse).toEqual( | ||||
|       expect.arrayContaining([ | ||||
|         { | ||||
|           ack: expect.any(String), | ||||
|           data: { | ||||
|             deletedAt: null, | ||||
|             email: auth.user.email, | ||||
|             id: auth.user.id, | ||||
|             name: updated.name, | ||||
|           }, | ||||
|           type: 'UserV1', | ||||
|         }, | ||||
|       ]), | ||||
|     ); | ||||
|   }); | ||||
| }); | ||||
		Reference in New Issue
	
	Block a user